feat(auth): support external agent resources

Replace bundled agent profiles, API keys, plugin, and skill surfaces with dynamic OAuth client registration, delegated DPoP tokens, discoverable scopes, and Arazzo-backed direct upload workflows.

Refs realmroot/realmroot#115
This commit is contained in:
saltbo
2026-07-30 10:41:39 -04:00
parent 09fc8de901
commit d7ba55b9da
104 changed files with 24546 additions and 10083 deletions
-17
View File
@@ -113,17 +113,6 @@ jobs:
go-version-file: cmd/go.mod
cache-dependency-path: cmd/go.sum
- name: Package restish-zpan plugin
working-directory: cmd
run: bash scripts/package-restish-zpan.sh "${{ github.ref_name }}" ../dist/restish-zpan
- name: Verify restish-zpan plugin startup
working-directory: cmd
run: |
go build -trimpath -o /tmp/restish-zpan ./restish-zpan
/tmp/restish-zpan --rsh-plugin-manifest >/tmp/restish-zpan.manifest.cbor
/tmp/restish-zpan --rsh-plugin-commands >/tmp/restish-zpan.commands.cbor
- name: Generate changelog
id: changelog
uses: requarks/changelog-action@v1
@@ -138,15 +127,9 @@ jobs:
body: |
${{ steps.changelog.outputs.changes }}
### Restish plugin
```bash
restish plugin install saltbo/zpan zpan
```
### Docker
```bash
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}-cli
```
generate_release_notes: false
files: dist/restish-zpan/*
-2
View File
@@ -164,8 +164,6 @@ After startup:
## Documentation
- [v2 Launch Offers](docs/v2-launch-offers.md) — earn ZPan Pro for free
- [ZPan Agent Skill](docs/agent-skill.md) — agent workflows for Restish setup, least-privilege profiles, uploads, CI, and MCP
- [Restish ZPan upload plugin](docs/restish-zpan.md) — install `restish-zpan` and upload local files through Restish profiles
- [Roadmap](V2_ROADMAP.md)
- [Contributing](CONTRIBUTING.md)
-3
View File
@@ -9,7 +9,6 @@ require (
github.com/docker/go-units v0.5.0
github.com/oapi-codegen/runtime v1.4.1
github.com/oschwald/geoip2-golang v1.13.0
github.com/rest-sh/restish/v2 v2.3.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
golang.org/x/sys v0.45.0
@@ -21,7 +20,6 @@ require (
github.com/avast/retry-go v3.0.0+incompatible // indirect
github.com/cenkalti/hub v1.0.1-0.20160527103212-11382a9960d3 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -37,7 +35,6 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/net v0.55.0 // indirect
-6
View File
@@ -26,8 +26,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -61,8 +59,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rest-sh/restish/v2 v2.3.0 h1:kbeHH4uvEYsgi1ShS8M5M/qkWHQVlQ6L8b3NGcJA6NE=
github.com/rest-sh/restish/v2 v2.3.0/go.mod h1:5i2g3d6x84yp4NvJ8c7gUPYoMSeaeXIUizC3CQLpHRU=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -88,8 +84,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
File diff suppressed because it is too large Load Diff
-179
View File
@@ -1,179 +0,0 @@
package restishzpan
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"time"
)
const checkpointVersion = 1
type checkpoint struct {
Version int `json:"version"`
API string `json:"api"`
Profile string `json:"profile,omitempty"`
SourcePath string `json:"sourcePath"`
FileSize int64 `json:"fileSize"`
ModTimeUnixNS int64 `json:"modTimeUnixNs"`
ObjectID string `json:"objectId"`
SessionID string `json:"sessionId"`
UploadID *string `json:"uploadId"`
Mode string `json:"mode"`
PartSize int64 `json:"partSize"`
PartCount int `json:"partCount"`
Parent string `json:"parent"`
Name string `json:"name"`
Conflict string `json:"conflict"`
Parts map[int]string `json:"parts"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func newCheckpoint(opts uploadOptions, src fileIdentity, matter matterResult, upload uploadInstructions) checkpoint {
return checkpoint{
Version: checkpointVersion,
API: opts.API,
Profile: opts.Profile,
SourcePath: src.Path,
FileSize: src.Size,
ModTimeUnixNS: src.ModTime.UnixNano(),
ObjectID: matter.ID,
SessionID: upload.SessionID,
UploadID: upload.UploadID,
Mode: upload.Mode,
PartSize: upload.PartSize,
PartCount: upload.PartCount,
Parent: opts.Parent,
Name: opts.Name,
Conflict: opts.Conflict,
Parts: map[int]string{},
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
}
func (c checkpoint) completedParts() []completedPart {
parts := make([]completedPart, 0, len(c.Parts))
for partNumber, etag := range c.Parts {
parts = append(parts, completedPart{PartNumber: partNumber, ETag: etag})
}
sort.Slice(parts, func(i, j int) bool { return parts[i].PartNumber < parts[j].PartNumber })
return parts
}
type checkpointStore struct {
dir string
}
func newCheckpointStore(dir string) (checkpointStore, error) {
if dir == "" {
cacheDir, err := os.UserCacheDir()
if err != nil {
return checkpointStore{}, err
}
dir = filepath.Join(cacheDir, "restish-zpan", "checkpoints")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return checkpointStore{}, err
}
return checkpointStore{dir: dir}, nil
}
func (s checkpointStore) path(opts uploadOptions, src fileIdentity) string {
key := opts.API + "\x00" + opts.Profile + "\x00" + src.Path + "\x00" + opts.Parent + "\x00" + opts.Name
sum := sha256.Sum256([]byte(key))
return filepath.Join(s.dir, hex.EncodeToString(sum[:])+".json")
}
func (s checkpointStore) load(path string) (checkpoint, error) {
data, err := os.ReadFile(path)
if err != nil {
return checkpoint{}, err
}
var cp checkpoint
if err := json.Unmarshal(data, &cp); err != nil {
return checkpoint{}, err
}
if cp.Version != checkpointVersion {
return checkpoint{}, fmt.Errorf("unsupported checkpoint version %d", cp.Version)
}
if cp.Parts == nil {
cp.Parts = map[int]string{}
}
return cp, nil
}
func (s checkpointStore) save(path string, cp checkpoint) error {
cp.UpdatedAt = time.Now().UTC()
data, err := json.MarshalIndent(cp, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(s.dir, ".checkpoint-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if runtime.GOOS != "windows" {
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return err
}
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if runtime.GOOS != "windows" {
if err := os.Chmod(tmpName, 0o600); err != nil {
return err
}
}
return os.Rename(tmpName, path)
}
func (s checkpointStore) remove(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func validateCheckpoint(cp checkpoint, opts uploadOptions, src fileIdentity) error {
if cp.API != opts.API || cp.Profile != opts.Profile {
return fmt.Errorf("checkpoint belongs to api/profile %s/%s", cp.API, cp.Profile)
}
if cp.SourcePath != src.Path {
return fmt.Errorf("checkpoint source changed")
}
if cp.FileSize != src.Size || cp.ModTimeUnixNS != src.ModTime.UnixNano() {
return fmt.Errorf("source file changed since checkpoint was created")
}
if cp.Parent != opts.Parent || cp.Name != opts.Name || cp.Conflict != opts.Conflict {
return fmt.Errorf("checkpoint destination or conflict policy differs from this command")
}
return nil
}
func validateAbortCheckpoint(cp checkpoint, opts uploadOptions, src fileIdentity) error {
if cp.API != opts.API || cp.Profile != opts.Profile {
return fmt.Errorf("checkpoint belongs to api/profile %s/%s", cp.API, cp.Profile)
}
if cp.SourcePath != src.Path {
return fmt.Errorf("checkpoint source changed")
}
if cp.Parent != opts.Parent || cp.Name != opts.Name {
return fmt.Errorf("checkpoint destination differs from this command")
}
return nil
}
-106
View File
@@ -1,106 +0,0 @@
package restishzpan
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCheckpointStoreRoundTripAndDefaults(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
store, err := newCheckpointStore("")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(store.dir, "restish-zpan") {
t.Fatalf("unexpected default dir: %s", store.dir)
}
path := filepath.Join(store.dir, "cp.json")
if err := store.save(path, checkpoint{Version: checkpointVersion}); err != nil {
t.Fatal(err)
}
cp, err := store.load(path)
if err != nil {
t.Fatal(err)
}
if cp.Parts == nil || len(cp.Parts) != 0 {
t.Fatalf("expected empty parts map, got %#v", cp.Parts)
}
if cp.UpdatedAt.IsZero() {
t.Fatal("expected UpdatedAt to be set")
}
}
func TestCheckpointStoreLoadAndRemoveErrors(t *testing.T) {
store, err := newCheckpointStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
invalidVersionPath := filepath.Join(store.dir, "invalid-version.json")
if err := os.WriteFile(invalidVersionPath, []byte(`{"version":2}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := store.load(invalidVersionPath); err == nil || !strings.Contains(err.Error(), "unsupported checkpoint version") {
t.Fatalf("expected version error, got %v", err)
}
invalidJSONPath := filepath.Join(store.dir, "invalid-json.json")
if err := os.WriteFile(invalidJSONPath, []byte(`{`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := store.load(invalidJSONPath); err == nil {
t.Fatal("expected invalid json to fail")
}
if err := store.remove(filepath.Join(store.dir, "missing.json")); err != nil {
t.Fatalf("remove missing file: %v", err)
}
}
func TestCheckpointCompletedPartsAreSorted(t *testing.T) {
parts := checkpoint{
Parts: map[int]string{2: "etag-2", 1: "etag-1"},
}.completedParts()
if len(parts) != 2 || parts[0].PartNumber != 1 || parts[1].PartNumber != 2 {
t.Fatalf("unexpected parts: %#v", parts)
}
}
func TestValidateAbortCheckpointIdentity(t *testing.T) {
src := fileIdentity{Path: "/tmp/source.bin"}
opts := uploadOptions{API: "zpan", Profile: "ci", Parent: "folder", Name: "source.bin", Conflict: "fail"}
cp := checkpoint{API: "zpan", Profile: "ci", SourcePath: src.Path, Parent: "folder", Name: "source.bin", Conflict: "rename"}
if err := validateAbortCheckpoint(cp, opts, src); err != nil {
t.Fatalf("expected abort validation to ignore conflict and file metadata, got %v", err)
}
if err := validateAbortCheckpoint(cp, uploadOptions{API: "other", Profile: "ci", Parent: "folder", Name: "source.bin"}, src); err == nil || !strings.Contains(err.Error(), "api/profile") {
t.Fatalf("expected api/profile error, got %v", err)
}
if err := validateAbortCheckpoint(cp, opts, fileIdentity{Path: "/tmp/other.bin"}); err == nil || !strings.Contains(err.Error(), "checkpoint source changed") {
t.Fatalf("expected source error, got %v", err)
}
if err := validateAbortCheckpoint(cp, uploadOptions{API: "zpan", Profile: "ci", Parent: "other", Name: "source.bin"}, src); err == nil || !strings.Contains(err.Error(), "destination differs") {
t.Fatalf("expected destination error, got %v", err)
}
}
func TestNewCheckpointCopiesUploadMetadata(t *testing.T) {
uploadID := "upload-1"
cp := newCheckpoint(
uploadOptions{API: "zpan", Profile: "ci", Parent: "folder", Name: "file.txt", Conflict: "rename"},
fileIdentity{Path: "/tmp/file.txt", Size: 5, ModTime: time.Unix(1, 0)},
matterResult{ID: "obj"},
uploadInstructions{SessionID: "sess", UploadID: &uploadID, Mode: "multipart", PartSize: 2, PartCount: 3},
)
if cp.API != "zpan" || cp.Profile != "ci" || cp.ObjectID != "obj" || cp.SessionID != "sess" || cp.Mode != "multipart" {
t.Fatalf("unexpected checkpoint: %#v", cp)
}
if cp.UploadID == nil || *cp.UploadID != uploadID {
t.Fatalf("unexpected upload id: %#v", cp.UploadID)
}
}
-82
View File
@@ -1,82 +0,0 @@
package restishzpan
import (
"context"
"encoding/json"
"fmt"
"github.com/rest-sh/restish/v2/plugin"
)
type host interface {
FetchAPISpecContext(ctx context.Context, api, profile string) (*plugin.APISpecResponseMsg, error)
Do(req *plugin.HTTPRequestMsg) (*plugin.HTTPResponseMsg, error)
Response(status int, headers map[string][]string, body any) error
Progress(text string) error
Warn(text string) error
}
type PluginHost struct {
client *plugin.CommandClient
}
func NewPluginHost(client *plugin.CommandClient) *PluginHost {
return &PluginHost{client: client}
}
func (h *PluginHost) FetchAPISpecContext(ctx context.Context, api, profile string) (*plugin.APISpecResponseMsg, error) {
return h.client.FetchAPISpecContext(ctx, api, profile)
}
func (h *PluginHost) Do(req *plugin.HTTPRequestMsg) (*plugin.HTTPResponseMsg, error) {
return h.client.Do(req)
}
func (h *PluginHost) Response(status int, headers map[string][]string, body any) error {
return h.client.Response(status, headers, body)
}
func (h *PluginHost) Progress(text string) error {
return h.client.Progress(text)
}
func (h *PluginHost) Warn(text string) error {
return h.client.Warn(text)
}
func decodeBody[T any](resp *plugin.HTTPResponseMsg) (T, error) {
var out T
if resp == nil {
return out, fmt.Errorf("missing HTTP response")
}
if resp.Error != "" {
return out, fmt.Errorf("%s", resp.Error)
}
if resp.Status < 200 || resp.Status >= 300 {
return out, httpStatusError{status: resp.Status, body: resp.Body}
}
data, err := json.Marshal(resp.Body)
if err != nil {
return out, fmt.Errorf("encode delegated response body: %w", err)
}
if err := json.Unmarshal(data, &out); err != nil {
return out, fmt.Errorf("decode delegated response body: %w", err)
}
return out, nil
}
type httpStatusError struct {
status int
body any
}
func (e httpStatusError) Error() string {
if e.body == nil {
return fmt.Sprintf("delegated request failed with HTTP %d", e.status)
}
data, err := json.Marshal(e.body)
if err != nil {
return fmt.Sprintf("delegated request failed with HTTP %d", e.status)
}
return fmt.Sprintf("delegated request failed with HTTP %d: %s", e.status, string(data))
}
-188
View File
@@ -1,188 +0,0 @@
package restishzpan
import (
"bytes"
"context"
"io"
"strings"
"testing"
"github.com/rest-sh/restish/v2/plugin"
)
func TestPluginHostFetchAPISpecContextDelegates(t *testing.T) {
hostToPluginR, hostToPluginW := newPipePair(t)
pluginToHostR, pluginToHostW := newPipePair(t)
client := plugin.NewCommandClient(hostToPluginR, pluginToHostW)
h := NewPluginHost(client)
requests := make(chan plugin.APISpecMsg, 1)
go func() {
defer close(requests)
var req plugin.APISpecMsg
if err := plugin.NewDecoder(pluginToHostR).ReadMessage(&req); err != nil {
t.Errorf("read request: %v", err)
return
}
requests <- req
if err := plugin.WriteMessage(hostToPluginW, plugin.APISpecResponseMsg{
Type: plugin.MsgTypeAPISpecResponse,
RequestID: req.RequestID,
Name: req.Name,
Profile: req.Profile,
Operations: []plugin.APIOperation{
{ID: opCreate, Method: "POST"},
},
}); err != nil {
t.Errorf("write response: %v", err)
}
}()
resp, err := h.FetchAPISpecContext(context.Background(), "zpan", "ci")
if err != nil {
t.Fatal(err)
}
req := <-requests
if req.Name != "zpan" || req.Profile != "ci" {
t.Fatalf("unexpected request: %#v", req)
}
if resp.Name != "zpan" || resp.Profile != "ci" || len(resp.Operations) != 1 || resp.Operations[0].ID != opCreate {
t.Fatalf("unexpected response: %#v", resp)
}
}
func TestPluginHostDoDelegates(t *testing.T) {
hostToPluginR, hostToPluginW := newPipePair(t)
pluginToHostR, pluginToHostW := newPipePair(t)
client := plugin.NewCommandClient(hostToPluginR, pluginToHostW)
h := NewPluginHost(client)
requests := make(chan plugin.HTTPRequestMsg, 1)
go func() {
defer close(requests)
var req plugin.HTTPRequestMsg
if err := plugin.NewDecoder(pluginToHostR).ReadMessage(&req); err != nil {
t.Errorf("read request: %v", err)
return
}
requests <- req
if err := plugin.WriteMessage(hostToPluginW, plugin.HTTPResponseMsg{
Type: plugin.MsgTypeHTTPResponse,
RequestID: req.RequestID,
Status: 200,
Body: map[string]any{"ok": true},
}); err != nil {
t.Errorf("write response: %v", err)
}
}()
resp, err := h.Do(&plugin.HTTPRequestMsg{Method: "POST", URI: "zpan/api/objects", Timeout: 1})
if err != nil {
t.Fatal(err)
}
req := <-requests
if req.Method != "POST" || req.URI != "zpan/api/objects" {
t.Fatalf("unexpected request: %#v", req)
}
if resp.Status != 200 {
t.Fatalf("status = %d, want 200", resp.Status)
}
}
func TestPluginHostWritesMessages(t *testing.T) {
var out bytes.Buffer
h := NewPluginHost(plugin.NewCommandClient(bytes.NewReader(nil), &out))
if err := h.Response(201, map[string][]string{"X-Test": {"1"}}, map[string]any{"id": "obj"}); err != nil {
t.Fatal(err)
}
if err := h.Progress("working"); err != nil {
t.Fatal(err)
}
if err := h.Warn("careful"); err != nil {
t.Fatal(err)
}
dec := plugin.NewDecoder(&out)
var resp plugin.ResponseMsg
if err := dec.ReadMessage(&resp); err != nil {
t.Fatal(err)
}
if resp.Type != plugin.MsgTypeResponse || resp.Status != 201 {
t.Fatalf("unexpected response message: %#v", resp)
}
var progress plugin.ProgressMsg
if err := dec.ReadMessage(&progress); err != nil {
t.Fatal(err)
}
if progress.Text != "working" {
t.Fatalf("unexpected progress: %#v", progress)
}
var warn plugin.WarnMsg
if err := dec.ReadMessage(&warn); err != nil {
t.Fatal(err)
}
if warn.Text != "careful" {
t.Fatalf("unexpected warn: %#v", warn)
}
}
func TestDecodeBody(t *testing.T) {
t.Run("success", func(t *testing.T) {
resp, err := decodeBody[map[string]string](&plugin.HTTPResponseMsg{Status: 200, Body: map[string]any{"id": "obj"}})
if err != nil {
t.Fatal(err)
}
if resp["id"] != "obj" {
t.Fatalf("unexpected body: %#v", resp)
}
})
t.Run("nil response", func(t *testing.T) {
_, err := decodeBody[map[string]any](nil)
if err == nil || !strings.Contains(err.Error(), "missing HTTP response") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("delegated error", func(t *testing.T) {
_, err := decodeBody[map[string]any](&plugin.HTTPResponseMsg{Status: 200, Error: "boom"})
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("status error", func(t *testing.T) {
_, err := decodeBody[map[string]any](&plugin.HTTPResponseMsg{Status: 400, Body: map[string]any{"error": "bad"}})
if err == nil || !strings.Contains(err.Error(), "HTTP 400") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("decode error", func(t *testing.T) {
_, err := decodeBody[struct {
ID string `json:"id"`
}](&plugin.HTTPResponseMsg{Status: 200, Body: map[string]any{"id": []string{"bad"}}})
if err == nil || !strings.Contains(err.Error(), "decode delegated response body") {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestHTTPStatusErrorFormatting(t *testing.T) {
if msg := (httpStatusError{status: 500}).Error(); !strings.Contains(msg, "HTTP 500") {
t.Fatalf("unexpected error: %s", msg)
}
if msg := (httpStatusError{status: 400, body: map[string]any{"error": "bad"}}).Error(); !strings.Contains(msg, `"error":"bad"`) {
t.Fatalf("unexpected error: %s", msg)
}
errBody := map[string]any{"bad": func() {}}
if msg := (httpStatusError{status: 502, body: errBody}).Error(); !strings.Contains(msg, "HTTP 502") {
t.Fatalf("unexpected error: %s", msg)
}
}
func newPipePair(t *testing.T) (*io.PipeReader, *io.PipeWriter) {
t.Helper()
return io.Pipe()
}
-79
View File
@@ -1,79 +0,0 @@
package restishzpan
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type storageClient interface {
PutPart(ctx context.Context, part uploadPart, body io.Reader, size int64) (string, error)
}
type httpStorageClient struct {
client *http.Client
}
func newHTTPStorageClient() httpStorageClient {
return httpStorageClient{client: &http.Client{Timeout: 0}}
}
func (c httpStorageClient) PutPart(ctx context.Context, part uploadPart, body io.Reader, size int64) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, part.URL, body)
if err != nil {
return "", err
}
req.ContentLength = size
for name, value := range part.Headers {
req.Header.Set(name, value)
}
resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", storageStatusError{status: resp.StatusCode}
}
etag := normalizeETag(resp.Header.Get("ETag"))
if etag == "" {
return "", fmt.Errorf("storage PUT for part %d did not return an ETag", part.PartNumber)
}
return etag, nil
}
type storageStatusError struct {
status int
}
func (e storageStatusError) Error() string {
return fmt.Sprintf("storage PUT failed with HTTP %d", e.status)
}
func normalizeETag(value string) string {
return strings.Trim(strings.TrimSpace(value), `"`)
}
func shouldResignAfterStorageError(err error) bool {
var statusErr storageStatusError
if !errors.As(err, &statusErr) {
return false
}
return statusErr.status == http.StatusForbidden || statusErr.status == http.StatusUnauthorized || statusErr.status == http.StatusBadRequest
}
func expiresSoon(raw string, now time.Time) bool {
if raw == "" {
return false
}
expires, err := time.Parse(time.RFC3339, raw)
if err != nil {
return false
}
return !expires.After(now.Add(30 * time.Second))
}
-113
View File
@@ -1,113 +0,0 @@
package restishzpan
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestHTTPStorageClientPutPart(t *testing.T) {
t.Run("success", func(t *testing.T) {
var gotType string
var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotType = r.Header.Get("Content-Type")
var err error
gotBody, err = ioReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
w.Header().Set("ETag", ` "etag-1" `)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := newHTTPStorageClient()
etag, err := client.PutPart(context.Background(), uploadPart{
PartNumber: 1,
URL: srv.URL,
Headers: map[string]string{"Content-Type": "text/plain"},
}, bytes.NewReader([]byte("abc")), 3)
if err != nil {
t.Fatal(err)
}
if etag != "etag-1" {
t.Fatalf("etag = %q, want %q", etag, "etag-1")
}
if gotType != "text/plain" || string(gotBody) != "abc" {
t.Fatalf("unexpected request: content-type=%q body=%q", gotType, gotBody)
}
})
t.Run("status error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "denied", http.StatusForbidden)
}))
defer srv.Close()
_, err := newHTTPStorageClient().PutPart(context.Background(), uploadPart{PartNumber: 2, URL: srv.URL}, bytes.NewReader([]byte("x")), 1)
var statusErr storageStatusError
if !errors.As(err, &statusErr) || statusErr.status != http.StatusForbidden {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("missing etag", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
_, err := newHTTPStorageClient().PutPart(context.Background(), uploadPart{PartNumber: 3, URL: srv.URL}, bytes.NewReader([]byte("x")), 1)
if err == nil || !strings.Contains(err.Error(), "did not return an ETag") {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestStorageHelpers(t *testing.T) {
if got := normalizeETag(` "abc" `); got != "abc" {
t.Fatalf("normalizeETag = %q", got)
}
if !shouldResignAfterStorageError(storageStatusError{status: http.StatusForbidden}) {
t.Fatal("expected forbidden to require re-sign")
}
if !shouldResignAfterStorageError(storageStatusError{status: http.StatusUnauthorized}) {
t.Fatal("expected unauthorized to require re-sign")
}
if !shouldResignAfterStorageError(storageStatusError{status: http.StatusBadRequest}) {
t.Fatal("expected bad request to require re-sign")
}
if shouldResignAfterStorageError(storageStatusError{status: http.StatusInternalServerError}) {
t.Fatal("expected internal server error not to require re-sign")
}
if shouldResignAfterStorageError(errors.New("plain")) {
t.Fatal("expected plain error not to require re-sign")
}
now := time.Now()
if expiresSoon("", now) {
t.Fatal("empty expiry should not expire soon")
}
if expiresSoon("not-a-time", now) {
t.Fatal("invalid expiry should not expire soon")
}
if !expiresSoon(now.Add(20*time.Second).Format(time.RFC3339), now) {
t.Fatal("expected near expiry to be true")
}
if expiresSoon(now.Add(2*time.Minute).Format(time.RFC3339), now) {
t.Fatal("expected distant expiry to be false")
}
}
func ioReadAll(body io.ReadCloser) ([]byte, error) {
defer body.Close()
return io.ReadAll(body)
}
-153
View File
@@ -1,153 +0,0 @@
package restishzpan
import (
"context"
"fmt"
"strings"
"github.com/rest-sh/restish/v2/plugin"
)
const (
opCreate = "createObject"
opPresign = "presignObjectUploadParts"
opComplete = "completeObjectUpload"
opAbort = "abortObjectUpload"
)
var restishOperationAliases = map[string][]string{
opCreate: {"create-object"},
opPresign: {"presign-object-upload-parts"},
opComplete: {"complete-object-upload"},
opAbort: {"abort-object-upload"},
}
type operationSet struct {
Create plugin.APIOperation
Presign plugin.APIOperation
Complete plugin.APIOperation
Abort plugin.APIOperation
}
func fetchOperations(ctx context.Context, h host, api, profile string) (operationSet, error) {
spec, err := h.FetchAPISpecContext(ctx, api, profile)
if err != nil {
return operationSet{}, err
}
if spec.Error != "" {
return operationSet{}, fmt.Errorf("%s", spec.Error)
}
ops := map[string]plugin.APIOperation{}
for _, op := range spec.Operations {
ops[op.ID] = op
}
required := map[string]string{
opCreate: "POST",
opPresign: "POST",
opComplete: "POST",
opAbort: "DELETE",
}
matched := map[string]plugin.APIOperation{}
for id, method := range required {
op, ok := findOperation(ops, id)
if !ok {
return operationSet{}, fmt.Errorf("API %q is missing required operation %q", api, id)
}
if !strings.EqualFold(op.Method, method) {
return operationSet{}, fmt.Errorf("operation %q uses %s, want %s", id, op.Method, method)
}
matched[id] = op
}
if err := validateCreateOperation(matched[opCreate]); err != nil {
return operationSet{}, err
}
if err := validatePartsOperation(matched[opPresign], "partNumbers"); err != nil {
return operationSet{}, err
}
if err := validatePartsOperation(matched[opComplete], "parts"); err != nil {
return operationSet{}, err
}
for _, id := range []string{opPresign, opComplete, opAbort} {
if err := requirePathParams(matched[id], "id", "uploadSessionId"); err != nil {
return operationSet{}, fmt.Errorf("operation %q: %w", id, err)
}
}
return operationSet{
Create: matched[opCreate],
Presign: matched[opPresign],
Complete: matched[opComplete],
Abort: matched[opAbort],
}, nil
}
func findOperation(ops map[string]plugin.APIOperation, id string) (plugin.APIOperation, bool) {
if op, ok := ops[id]; ok {
return op, true
}
for _, alias := range restishOperationAliases[id] {
if op, ok := ops[alias]; ok {
return op, true
}
}
return plugin.APIOperation{}, false
}
func validateCreateOperation(op plugin.APIOperation) error {
if !op.HasBody {
return fmt.Errorf("operation %q must accept a JSON body", opCreate)
}
for _, name := range []string{"name", "type", "size", "parent", "onConflict"} {
if !schemaHasProperty(op.RequestSchema, name) {
return fmt.Errorf("operation %q request schema missing %q", opCreate, name)
}
}
return nil
}
func validatePartsOperation(op plugin.APIOperation, property string) error {
if !op.HasBody {
return fmt.Errorf("operation %q must accept a JSON body", op.ID)
}
if !schemaHasProperty(op.RequestSchema, property) {
return fmt.Errorf("operation %q request schema missing %q", op.ID, property)
}
return nil
}
func schemaHasProperty(schema map[string]any, name string) bool {
if schema == nil {
return false
}
if props, ok := schema["properties"].(map[string]any); ok {
_, found := props[name]
return found
}
for _, key := range []string{"allOf", "anyOf", "oneOf"} {
items, ok := schema[key].([]any)
if !ok {
continue
}
for _, item := range items {
child, ok := item.(map[string]any)
if ok && schemaHasProperty(child, name) {
return true
}
}
}
return false
}
func requirePathParams(op plugin.APIOperation, names ...string) error {
seen := map[string]bool{}
for _, param := range op.Parameters {
if param.In == "path" && param.Required {
seen[param.Name] = true
}
}
for _, name := range names {
if !seen[name] {
return fmt.Errorf("missing required path parameter %q", name)
}
}
return nil
}
-70
View File
@@ -1,70 +0,0 @@
package restishzpan
import "time"
type uploadPart struct {
PartNumber int `json:"partNumber"`
URL string `json:"url"`
ExpiresAt string `json:"expiresAt"`
Headers map[string]string `json:"headers"`
}
type uploadInstructions struct {
SessionID string `json:"sessionId"`
UploadID *string `json:"uploadId"`
Mode string `json:"mode"`
PartSize int64 `json:"partSize"`
PartCount int `json:"partCount"`
ExpiresAt string `json:"expiresAt"`
PresignedExpiresAt string `json:"presignedExpiresAt"`
RequiredHeaders map[string]string `json:"requiredHeaders"`
Parts []uploadPart `json:"parts"`
}
type matterResult struct {
ID string `json:"id"`
OrgID string `json:"orgId,omitempty"`
Alias string `json:"alias,omitempty"`
Name string `json:"name"`
Type string `json:"type,omitempty"`
Size *int64 `json:"size,omitempty"`
Parent string `json:"parent,omitempty"`
Object string `json:"object,omitempty"`
StorageID string `json:"storageId,omitempty"`
Status string `json:"status,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Upload *uploadInstructions `json:"upload,omitempty"`
}
type presignPartsResult struct {
UploadID *string `json:"uploadId"`
Mode string `json:"mode"`
PartSize int64 `json:"partSize"`
PartCount int `json:"partCount"`
PresignedExpiresAt string `json:"presignedExpiresAt"`
RequiredHeaders map[string]string `json:"requiredHeaders"`
Parts []uploadPart `json:"parts"`
}
type completedPart struct {
PartNumber int `json:"partNumber"`
ETag string `json:"etag"`
}
type uploadResult struct {
Object matterResult `json:"object"`
Upload resultUpload `json:"upload"`
Checkpoint string `json:"checkpoint,omitempty"`
CompletedAt time.Time `json:"completedAt"`
}
type resultUpload struct {
API string `json:"api"`
Profile string `json:"profile,omitempty"`
SessionID string `json:"sessionId"`
Mode string `json:"mode"`
PartSize int64 `json:"partSize"`
PartCount int `json:"partCount"`
Parts []completedPart `json:"parts"`
}
-517
View File
@@ -1,517 +0,0 @@
package restishzpan
import (
"context"
"errors"
"flag"
"fmt"
"io"
"mime"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/rest-sh/restish/v2/plugin"
)
type uploadOptions struct {
API string
Profile string
Source string
Parent string
Name string
Conflict string
Concurrency int
Resume bool
Abort bool
CheckpointDir string
ContentType string
}
type fileIdentity struct {
Path string
Size int64
ModTime time.Time
}
func Run(startupArgs, args []string, h host) error {
if wantsHelp(args) {
return h.Response(200, nil, map[string]any{
"usage": "restish zpan-upload [flags] SOURCE [DESTINATION]",
"examples": []string{
"RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager ./photo.jpg",
"RSH_PROFILE=ci restish zpan-upload --api zpan --profile ci --parent folder-id ./photo.jpg report.jpg",
"RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --resume ./large.bin",
"RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --abort ./large.bin",
},
})
}
opts, err := parseOptions(args)
if err != nil {
return err
}
_ = startupArgs
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return runWithStorage(ctx, opts, h, newHTTPStorageClient())
}
func wantsHelp(args []string) bool {
for _, arg := range args {
if arg == "-h" || arg == "--help" {
return true
}
}
return false
}
func parseOptions(args []string) (uploadOptions, error) {
opts := uploadOptions{API: "zpan", Conflict: "fail", Concurrency: 4}
fs := flag.NewFlagSet("zpan-upload", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&opts.API, "api", opts.API, "Restish API name")
fs.StringVar(&opts.Profile, "profile", "", "Restish profile name")
fs.StringVar(&opts.Parent, "parent", "", "destination folder/object parent")
fs.StringVar(&opts.Parent, "folder", "", "destination folder/object parent")
fs.StringVar(&opts.Name, "name", "", "destination object name")
fs.StringVar(&opts.Conflict, "conflict", opts.Conflict, "conflict policy: fail, rename, replace")
fs.IntVar(&opts.Concurrency, "concurrency", opts.Concurrency, "maximum concurrent part uploads")
fs.BoolVar(&opts.Resume, "resume", false, "resume an interrupted upload from the local checkpoint")
fs.BoolVar(&opts.Abort, "abort", false, "abort the checkpointed upload session and delete the local checkpoint")
fs.StringVar(&opts.CheckpointDir, "checkpoint-dir", "", "checkpoint directory")
fs.StringVar(&opts.ContentType, "content-type", "", "override detected content type")
if err := fs.Parse(args); err != nil {
return uploadOptions{}, err
}
if opts.API == "" {
return uploadOptions{}, fmt.Errorf("--api is required")
}
if opts.Concurrency < 1 || opts.Concurrency > 32 {
return uploadOptions{}, fmt.Errorf("--concurrency must be between 1 and 32")
}
if opts.Conflict != "fail" && opts.Conflict != "rename" && opts.Conflict != "replace" {
return uploadOptions{}, fmt.Errorf("--conflict must be fail, rename, or replace")
}
positional := fs.Args()
if len(positional) < 1 || len(positional) > 2 {
return uploadOptions{}, fmt.Errorf("usage: restish zpan-upload [flags] SOURCE [DESTINATION]")
}
opts.Source = positional[0]
if opts.Name == "" {
opts.Name = filepath.Base(opts.Source)
}
if len(positional) == 2 {
parent, name := splitDestination(positional[1], opts.Name)
if opts.Parent == "" {
opts.Parent = parent
}
if name != "" {
opts.Name = name
}
}
return opts, nil
}
func splitDestination(dest, fallbackName string) (string, string) {
dest = filepath.ToSlash(strings.TrimSpace(dest))
if dest == "" {
return "", fallbackName
}
if strings.HasSuffix(dest, "/") {
return strings.TrimSuffix(dest, "/"), fallbackName
}
parent, name := filepath.Split(dest)
return strings.TrimSuffix(filepath.ToSlash(parent), "/"), name
}
func runWithStorage(ctx context.Context, opts uploadOptions, h host, storage storageClient) error {
store, err := newCheckpointStore(opts.CheckpointDir)
if err != nil {
return err
}
if opts.Abort {
src, err := sourcePathIdentity(opts.Source)
if err != nil {
return err
}
checkpointPath := store.path(opts, src)
ops, err := fetchOperations(ctx, h, opts.API, opts.Profile)
if err != nil {
return err
}
return abortCheckpoint(ctx, opts, h, store, checkpointPath, src, ops)
}
src, err := statSource(opts.Source)
if err != nil {
return err
}
if opts.ContentType == "" {
opts.ContentType = detectContentType(src.Path)
}
checkpointPath := store.path(opts, src)
ops, err := fetchOperations(ctx, h, opts.API, opts.Profile)
if err != nil {
return err
}
cp, initialParts, err := prepareUpload(ctx, opts, h, store, checkpointPath, src, ops)
if err != nil {
return err
}
if err := uploadMissingParts(ctx, opts, h, storage, store, checkpointPath, src, ops, &cp, initialParts); err != nil {
return err
}
object, err := completeUpload(ctx, opts, h, ops, cp)
if err != nil {
return err
}
if err := store.remove(checkpointPath); err != nil {
return err
}
return h.Response(200, nil, uploadResult{
Object: object,
Upload: resultUpload{
API: opts.API,
Profile: opts.Profile,
SessionID: cp.SessionID,
Mode: cp.Mode,
PartSize: cp.PartSize,
PartCount: cp.PartCount,
Parts: cp.completedParts(),
},
CompletedAt: time.Now().UTC(),
})
}
func statSource(path string) (fileIdentity, error) {
abs, err := filepath.Abs(path)
if err != nil {
return fileIdentity{}, err
}
info, err := os.Stat(abs)
if err != nil {
return fileIdentity{}, err
}
if info.IsDir() {
return fileIdentity{}, fmt.Errorf("source must be a file: %s", abs)
}
return fileIdentity{Path: abs, Size: info.Size(), ModTime: info.ModTime()}, nil
}
func sourcePathIdentity(path string) (fileIdentity, error) {
abs, err := filepath.Abs(path)
if err != nil {
return fileIdentity{}, err
}
return fileIdentity{Path: abs}, nil
}
func detectContentType(path string) string {
return mime.TypeByExtension(filepath.Ext(path))
}
func prepareUpload(ctx context.Context, opts uploadOptions, h host, store checkpointStore, checkpointPath string, src fileIdentity, ops operationSet) (checkpoint, []uploadPart, error) {
if opts.Resume {
cp, err := store.load(checkpointPath)
if err != nil {
return checkpoint{}, nil, err
}
if err := validateCheckpoint(cp, opts, src); err != nil {
return checkpoint{}, nil, err
}
return cp, nil, nil
}
body := map[string]any{
"name": opts.Name,
"size": src.Size,
"parent": opts.Parent,
"onConflict": opts.Conflict,
}
if opts.ContentType != "" {
body["type"] = opts.ContentType
}
resp, err := h.Do(&plugin.HTTPRequestMsg{
Method: ops.Create.Method,
URI: opts.API + ops.Create.Path,
Body: body,
ContentType: "application/json",
NoCache: true,
Timeout: 60,
})
if err != nil {
return checkpoint{}, nil, err
}
matter, err := decodeBody[matterResult](resp)
if err != nil {
return checkpoint{}, nil, err
}
if matter.Upload == nil {
return checkpoint{}, nil, fmt.Errorf("createObject did not return upload instructions for file draft")
}
cp := newCheckpoint(opts, src, matter, *matter.Upload)
if err := store.save(checkpointPath, cp); err != nil {
return checkpoint{}, nil, err
}
return cp, matter.Upload.Parts, nil
}
func uploadMissingParts(ctx context.Context, opts uploadOptions, h host, storage storageClient, store checkpointStore, checkpointPath string, src fileIdentity, ops operationSet, cp *checkpoint, initialParts []uploadPart) error {
missing := missingPartNumbers(*cp)
parts, err := currentParts(ctx, opts, h, ops, *cp, missing, initialParts)
if err != nil {
return err
}
partByNumber := map[int]uploadPart{}
for _, part := range parts {
partByNumber[part.PartNumber] = part
}
work := make(chan int)
errs := make(chan error, 1)
var mu sync.Mutex
var wg sync.WaitGroup
workers := min(opts.Concurrency, cp.PartCount)
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for partNumber := range work {
part := partByNumber[partNumber]
etag, err := putPartWithRetry(ctx, opts, h, storage, ops, *cp, src, part)
if err != nil {
select {
case errs <- err:
default:
}
continue
}
mu.Lock()
cp.Parts[partNumber] = etag
saveErr := store.save(checkpointPath, *cp)
mu.Unlock()
if saveErr != nil {
select {
case errs <- saveErr:
default:
}
}
_ = h.Progress(fmt.Sprintf("uploaded part %d/%d", partNumber, cp.PartCount))
}
}()
}
for _, partNumber := range missing {
select {
case <-ctx.Done():
close(work)
wg.Wait()
return ctx.Err()
case err := <-errs:
close(work)
wg.Wait()
return err
case work <- partNumber:
}
}
close(work)
wg.Wait()
select {
case err := <-errs:
return err
default:
return nil
}
}
func currentParts(ctx context.Context, opts uploadOptions, h host, ops operationSet, cp checkpoint, partNumbers []int, initialParts []uploadPart) ([]uploadPart, error) {
if len(partNumbers) == 0 {
return nil, nil
}
if len(initialParts) > 0 {
byNumber := map[int]uploadPart{}
for _, part := range initialParts {
byNumber[part.PartNumber] = part
}
parts := make([]uploadPart, 0, len(partNumbers))
for _, partNumber := range partNumbers {
part, ok := byNumber[partNumber]
if !ok {
return nil, fmt.Errorf("createObject did not return upload instructions for part %d", partNumber)
}
parts = append(parts, part)
}
return parts, nil
}
return resignParts(ctx, opts, h, ops, cp, partNumbers)
}
func putPartWithRetry(ctx context.Context, opts uploadOptions, h host, storage storageClient, ops operationSet, cp checkpoint, src fileIdentity, part uploadPart) (string, error) {
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
if part.URL == "" || expiresSoon(part.ExpiresAt, time.Now()) {
parts, err := resignParts(ctx, opts, h, ops, cp, []int{part.PartNumber})
if err != nil {
return "", err
}
if len(parts) != 1 || parts[0].PartNumber != part.PartNumber {
return "", fmt.Errorf("re-sign response missing part %d", part.PartNumber)
}
part = parts[0]
}
etag, err := putPart(ctx, storage, src, cp.PartSize, part)
if err == nil {
return etag, nil
}
lastErr = err
if shouldResignAfterStorageError(err) {
part.URL = ""
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Duration(attempt) * 200 * time.Millisecond):
}
}
return "", lastErr
}
func putPart(ctx context.Context, storage storageClient, src fileIdentity, partSize int64, part uploadPart) (string, error) {
file, err := os.Open(src.Path)
if err != nil {
return "", err
}
defer file.Close()
offset, size := partRange(src.Size, partSize, part.PartNumber)
reader := io.NewSectionReader(file, offset, size)
return storage.PutPart(ctx, part, reader, size)
}
func partRange(fileSize, partSize int64, partNumber int) (int64, int64) {
if fileSize == 0 {
return 0, 0
}
offset := int64(partNumber-1) * partSize
size := partSize
if remaining := fileSize - offset; remaining < size {
size = remaining
}
return offset, size
}
func missingPartNumbers(cp checkpoint) []int {
missing := make([]int, 0, cp.PartCount-len(cp.Parts))
for i := 1; i <= cp.PartCount; i++ {
if cp.Parts[i] == "" {
missing = append(missing, i)
}
}
return missing
}
func resignParts(ctx context.Context, opts uploadOptions, h host, ops operationSet, cp checkpoint, partNumbers []int) ([]uploadPart, error) {
var all []uploadPart
for start := 0; start < len(partNumbers); start += 100 {
end := min(start+100, len(partNumbers))
parts, err := resignPartBatch(ctx, opts, h, ops, cp, partNumbers[start:end])
if err != nil {
return nil, err
}
all = append(all, parts...)
}
sort.Slice(all, func(i, j int) bool { return all[i].PartNumber < all[j].PartNumber })
seen := map[int]bool{}
for _, part := range all {
seen[part.PartNumber] = true
}
for _, partNumber := range partNumbers {
if !seen[partNumber] {
return nil, fmt.Errorf("re-sign response missing part %d", partNumber)
}
}
return all, nil
}
func resignPartBatch(ctx context.Context, opts uploadOptions, h host, ops operationSet, cp checkpoint, partNumbers []int) ([]uploadPart, error) {
resp, err := h.Do(&plugin.HTTPRequestMsg{
Method: ops.Presign.Method,
URI: opts.API + expandUploadPath(ops.Presign.Path, cp),
Body: map[string]any{"partNumbers": partNumbers},
ContentType: "application/json",
NoCache: true,
Timeout: 60,
})
if err != nil {
return nil, err
}
_ = ctx
result, err := decodeBody[presignPartsResult](resp)
if err != nil {
return nil, err
}
sort.Slice(result.Parts, func(i, j int) bool { return result.Parts[i].PartNumber < result.Parts[j].PartNumber })
return result.Parts, nil
}
func completeUpload(ctx context.Context, opts uploadOptions, h host, ops operationSet, cp checkpoint) (matterResult, error) {
parts := cp.completedParts()
if len(parts) != cp.PartCount {
return matterResult{}, fmt.Errorf("cannot complete: %d of %d parts uploaded", len(parts), cp.PartCount)
}
resp, err := h.Do(&plugin.HTTPRequestMsg{
Method: ops.Complete.Method,
URI: opts.API + expandUploadPath(ops.Complete.Path, cp),
Body: map[string]any{"parts": parts},
ContentType: "application/json",
NoCache: true,
Timeout: 120,
})
if err != nil {
return matterResult{}, err
}
_ = ctx
return decodeBody[matterResult](resp)
}
func abortCheckpoint(ctx context.Context, opts uploadOptions, h host, store checkpointStore, checkpointPath string, src fileIdentity, ops operationSet) error {
cp, err := store.load(checkpointPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("no checkpoint found for %s", src.Path)
}
return err
}
if err := validateAbortCheckpoint(cp, opts, src); err != nil {
return err
}
resp, err := h.Do(&plugin.HTTPRequestMsg{
Method: ops.Abort.Method,
URI: opts.API + expandUploadPath(ops.Abort.Path, cp),
NoCache: true,
Timeout: 60,
})
if err != nil {
return err
}
if _, err := decodeBody[map[string]any](resp); err != nil && resp.Status != 204 {
return err
}
if err := store.remove(checkpointPath); err != nil {
return err
}
_ = ctx
return h.Response(200, nil, map[string]any{
"aborted": true,
"api": opts.API,
"profile": opts.Profile,
"objectId": cp.ObjectID,
"sessionId": cp.SessionID,
})
}
func expandUploadPath(path string, cp checkpoint) string {
out := strings.ReplaceAll(path, "{id}", cp.ObjectID)
out = strings.ReplaceAll(out, "{uploadSessionId}", cp.SessionID)
return out
}
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/rest-sh/restish/v2/plugin"
"github.com/saltbo/zpan/internal/restishzpan"
)
var version = "dev"
func main() {
plugin.Run(manifest(), commands(), runCommand)
}
func manifest() plugin.Manifest {
return plugin.Manifest{
Name: "zpan",
Version: version,
Description: "ZPan upload workflow commands for Restish",
RestishAPIVersion: 2,
Hooks: []string{"command"},
NeedsAuthSecrets: false,
}
}
func commands() []plugin.CommandDecl {
return []plugin.CommandDecl{
{
Name: "zpan-upload",
Short: "Upload a local file to ZPan",
Long: "Upload a local file to ZPan using Restish-managed API auth and direct presigned storage PUTs.\n\n" +
"Examples:\n" +
" RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager ./photo.jpg\n" +
" RSH_PROFILE=ci restish zpan-upload --api zpan --profile ci --parent folder-id ./photo.jpg report.jpg\n" +
" RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --resume ./large.bin\n" +
" RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --abort ./large.bin",
},
}
}
func runCommand(command string, args []string, client *plugin.CommandClient) error {
if command != "zpan-upload" {
return fmt.Errorf("unknown command: %s", command)
}
return restishzpan.Run(os.Args[1:], args, restishzpan.NewPluginHost(client))
}
-167
View File
@@ -1,167 +0,0 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
"github.com/rest-sh/restish/v2/plugin"
)
func TestManifestContract(t *testing.T) {
var out bytes.Buffer
err := plugin.WriteManifest(&out, manifest())
if err != nil {
t.Fatal(err)
}
var manifest plugin.Manifest
if err := plugin.NewDecoder(&out).ReadMessage(&manifest); err != nil {
t.Fatal(err)
}
if manifest.Name != "zpan" || manifest.NeedsAuthSecrets || len(manifest.Hooks) != 1 || manifest.Hooks[0] != "command" {
t.Fatalf("unexpected manifest: %#v", manifest)
}
}
func TestCommandDiscoveryContract(t *testing.T) {
var out bytes.Buffer
if err := plugin.WriteCommands(&out, commands()); err != nil {
t.Fatal(err)
}
var discovery plugin.CommandDiscoveryResponse
if err := plugin.NewDecoder(&out).ReadMessage(&discovery); err != nil {
t.Fatal(err)
}
if len(discovery.Commands) != 1 || discovery.Commands[0].Name != "zpan-upload" {
t.Fatalf("unexpected commands: %#v", discovery.Commands)
}
help := discovery.Commands[0].Long
if !strings.Contains(help, "RSH_PROFILE=file-manager") || strings.Contains(help, "--rsh-profile") {
t.Fatalf("upload help must use the delegated HTTP profile environment: %q", help)
}
}
func TestRunCommandRejectsUnknownCommand(t *testing.T) {
err := runCommand("other", nil, plugin.NewCommandClient(bytes.NewReader(nil), io.Discard))
if err == nil {
t.Fatal("expected unknown command to fail")
}
}
func TestRunCommandDelegatesKnownCommand(t *testing.T) {
err := runCommand("zpan-upload", nil, plugin.NewCommandClient(bytes.NewReader(nil), io.Discard))
if err == nil {
t.Fatal("expected delegated parser error")
}
}
func TestMainStartupFlags(t *testing.T) {
t.Run("manifest", func(t *testing.T) {
data := captureStdout(t, []string{"restish-zpan", plugin.StartupFlagManifest}, main)
var manifest plugin.Manifest
if err := plugin.NewDecoder(bytes.NewReader(data)).ReadMessage(&manifest); err != nil {
t.Fatal(err)
}
if manifest.Name != "zpan" || manifest.RestishAPIVersion != 2 {
t.Fatalf("unexpected manifest: %#v", manifest)
}
})
t.Run("commands", func(t *testing.T) {
data := captureStdout(t, []string{"restish-zpan", plugin.StartupFlagCommands}, main)
var discovery plugin.CommandDiscoveryResponse
if err := plugin.NewDecoder(bytes.NewReader(data)).ReadMessage(&discovery); err != nil {
t.Fatal(err)
}
if len(discovery.Commands) != 1 || discovery.Commands[0].Name != "zpan-upload" {
t.Fatalf("unexpected commands: %#v", discovery.Commands)
}
})
}
func TestMainCommandErrorPath(t *testing.T) {
oldArgs := os.Args
oldStdin := os.Stdin
oldStdout := os.Stdout
defer func() {
os.Args = oldArgs
os.Stdin = oldStdin
os.Stdout = oldStdout
}()
inR, inW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
outR, outW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Args = []string{"restish-zpan"}
os.Stdin = inR
os.Stdout = outW
go func() {
defer inW.Close()
_ = plugin.WriteMessage(inW, plugin.InitMsg{Type: plugin.MsgTypeInit, Command: "unknown"})
}()
main()
if err := outW.Close(); err != nil {
t.Fatal(err)
}
var stderr plugin.StderrDataMsg
dec := plugin.NewDecoder(outR)
if err := dec.ReadMessage(&stderr); err != nil {
t.Fatal(err)
}
if !bytes.Contains(stderr.Data, []byte("unknown command: unknown")) {
t.Fatalf("unexpected stderr: %q", stderr.Data)
}
var done plugin.DoneMsg
if err := dec.ReadMessage(&done); err != nil {
t.Fatal(err)
}
if done.ExitCode != 1 {
t.Fatalf("exit code = %d, want 1", done.ExitCode)
}
if err := outR.Close(); err != nil {
t.Fatal(err)
}
if err := inR.Close(); err != nil {
t.Fatal(err)
}
}
func captureStdout(t *testing.T, args []string, fn func()) []byte {
t.Helper()
oldArgs := os.Args
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Args = args
os.Stdout = w
defer func() {
os.Args = oldArgs
os.Stdout = oldStdout
}()
fn()
if err := w.Close(); err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
return data
}
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
version="${1:?version is required}"
out_dir="${2:?output directory is required}"
mkdir -p "$out_dir"
out_dir="$(cd "$out_dir" && pwd)"
targets=(
"darwin amd64 tar.gz"
"darwin arm64 tar.gz"
"linux amd64 tar.gz"
"linux arm64 tar.gz"
"windows amd64 zip"
"windows arm64 zip"
)
for target in "${targets[@]}"; do
(
read -r goos goarch ext <<<"$target"
work_dir="$(mktemp -d)"
trap 'rm -rf "$work_dir"' EXIT
bin_name="restish-zpan"
if [ "$goos" = "windows" ]; then
bin_name="restish-zpan.exe"
fi
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \
go build -trimpath -ldflags "-s -w -X main.version=${version}" \
-o "${work_dir}/${bin_name}" ./restish-zpan
archive="${out_dir}/restish-zpan_${goos}_${goarch}.${ext}"
if [ "$ext" = "zip" ]; then
(cd "$work_dir" && zip -q "$archive" "$bin_name")
else
tar -C "$work_dir" -czf "$archive" "$bin_name"
fi
)
done
-51
View File
@@ -1,51 +0,0 @@
# ZPan Agent Skill
ZPan v2.9 publishes a versioned Agent Skill in [skills/zpan](../skills/zpan).
The Skill teaches coding agents to use ZPan through Restish and the
`restish-zpan` upload plugin.
## Install and Connect
Install Restish v2.3 or later, confirm the ZPan origin, then connect the single
unified OpenAPI document:
```sh
restish api connect zpan https://files.example.com/api/openapi.json --replace --yes
restish api sync zpan
```
Interactive agents use browser OAuth authorization code + PKCE through Restish.
CI and unattended jobs use the `ci` profile with `ZPAN_AGENT_API_KEY` from the
environment.
## Upload Plugin
Before installing the plugin, tell the user that Restish plugins are trusted
local executable code and ask them to approve the source:
```sh
restish plugin install saltbo/zpan zpan
```
Every local upload goes through:
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent root ./file.bin
```
The Skill does not implement upload transport logic. The plugin owns local file
streaming, storage response capture, retry, resume, abort, and checkpoint
cleanup.
## Profiles
- `reader`: read objects, shares, quota, and storage usage.
- `file-manager`: reader plus create, upload, move, copy, rename, and soft
delete objects.
- `publisher`: reader plus public share creation and revocation.
- `ci`: environment-backed Agent API key for unattended file-management jobs.
The profile names are shortcuts for explicit scopes. They are not server-side
roles and routes do not authorize by preset name.
Interactive Restish login uses browser OAuth authorization code + PKCE.
+166 -566
View File
@@ -1,604 +1,204 @@
# Agent Authentication and Authorization — Design
# External Agent Access — Design
> Status: Proposed (2026-07-28)
> Scope: Agent OAuth, API keys, workspace grants, protocol-neutral
> authorization, Restish profiles, future Agent Auth compatibility, revocation,
> and auditing
> Status: Implemented
> Scope: dynamic OAuth clients, external resource authorization, DPoP, resource
> discovery, consent, revocation, and direct uploads
## 1. Decision
## Decision
ZPan distinguishes delegated user access from unattended service access:
ZPan is an OAuth protected resource and authorization server. An Agent platform
such as FlareAuth discovers ZPan from its public API URL, dynamically registers
itself, asks the user for delegated access, and exchanges the resulting subject
grant for a DPoP-bound ZPan resource token.
| Actor | Authorization flow | Runtime credential |
|-------|--------------------|--------------------|
| Interactive Agent, local callback | Authorization code + PKCE | OAuth access/refresh tokens |
| CI or unattended service | Manual issuance | Workspace-scoped Agent API key |
ZPan does not ship or require:
This follows the current FlareAuth Restish v2 design: standard OpenAPI OAuth
metadata and `x-cli-config` let Restish connect, authorize, cache, refresh, and
revoke local tokens without a custom authorization script.
- a fixed first-party Agent OAuth client;
- an Agent-specific API key;
- Restish credential profiles in OpenAPI;
- a Restish upload plugin;
- a ZPan-specific Agent skill.
Standard Agent device authorization is deferred to v2.9.x. The existing
`zpan-cli` device flow remains a narrowly scoped compatibility bootstrap for
downloader registration and does not manufacture an Agent API key or a general
OAuth grant. Its device-issued bearer is normalized as a single-use downloader
registration credential and is consumed after successful downloader creation.
The integration contract is the public protocol surface: OAuth metadata,
OpenAPI, route authorization metadata, and structured API responses.
Anonymous upload and preview-and-claim are explicitly excluded. Every Agent file
operation belongs to an existing user-authorized workspace from the beginning.
## Discovery
OAuth and API keys are v2.9 credential adapters, not the file API's identity
model. Both resolve to a protocol-neutral principal, scope set, bound workspace,
and audit actor. A future Agent Auth verifier plugs into that same boundary.
Given the exact resource URL `https://zpan.example/api`, a client can discover:
## 2. Why OAuth for Interactive Agents
| Contract | Path |
|---|---|
| API, OpenAPI, and workflow discovery links | `/api` |
| OpenAPI | `/api/openapi.json` |
| Arazzo workflows | `/api/workflows.arazzo.json` |
| Protected resource metadata | `/.well-known/oauth-protected-resource/api` |
| Authorization server metadata | `/.well-known/oauth-authorization-server/api/auth` |
| Dynamic client registration | `/api/auth/oauth2/register` |
Interactive Agents act on behalf of a signed-in human. OAuth gives that
relationship first-class semantics:
Protected-resource metadata identifies the exact `/api` audience and the
authorization server. Authorization-server metadata advertises authorization
code, refresh token, JWT bearer, token exchange, dynamic registration, and
DPoP capabilities.
- short-lived access tokens
- refresh-token rotation and revocation
- explicit client identity
- explicit resource scopes
- browser consent
- authorization code + PKCE for public native clients
- no browser-cookie or raw-token copy/paste
OpenAPI remains tool-neutral. It contains no `x-cli-config`, built-in client ID,
credential environment variable, or executable helper. Agent-callable
operations publish their exact runtime requirements through `x-zpan-auth`.
`GET /api/oauth-resource-scopes` is a public scope catalog whose OpenAPI
operation carries the standard OAuth scope declaration used by external
resource registries. Keeping the business operations themselves unbound avoids
selecting a built-in Restish OAuth profile before a delegated-credential hook
can provide the resource token. Browser and administration operations retain
their normal cookie/bearer declarations.
Restish v2 natively supports authorization code + PKCE. It caches OAuth tokens
separately from HTTP responses, refreshes them, retries once after a `401`, and
supports explicit logout.
The API resource response publishes OpenAPI through an RFC 8631 `service-desc`
link and its Arazzo 1.1 description through a typed `describedby` link. The
OpenAPI document also links the Arazzo document through `externalDocs`. A
controller can therefore discover both contracts from the exact resource URL
without assuming a ZPan-specific path.
Restish v2.3 uses port `8484` and path `/callback` by default for browser
authorization-code callbacks. Restish sends `localhost` in the authorization
request; ZPan also registers the equivalent `127.0.0.1` loopback callback for
clients and tooling that distinguish loopback hostnames.
The Arazzo document defines separate prepare, re-presign, complete, and abort
workflows backed by stable OpenAPI operation IDs. Preparing an upload returns
the runtime descriptor for the direct storage transfer. This split is
intentional: an Arazzo operation target comes from its source OpenAPI server,
while a presigned storage URL is an arbitrary absolute URL generated at
runtime. The controller executes those PUT requests from the returned
descriptor, then supplies their ETags to the completion workflow.
## 3. Why API Keys Still Exist
## Dynamic Registration and Administration
CI and unattended services are different: no human is present to complete
consent or periodically reauthorize. The existing Better Auth API-key
foundation already supplies:
The OAuth provider accepts RFC 7591-style dynamic client registration with PKCE.
Each controller registers its own:
- hashed credential storage
- named and independently revocable keys
- expiry and enabled state
- rate-limit state
- resource/action permissions
- workspace scope in metadata
- owning user reference
- per-key audit attribution
- client name and URI;
- callback URI;
- grant and response types;
- token endpoint authentication method;
- JWKS or JWKS URI when JWT bearer exchange is used;
- requested scopes.
An Agent API key is therefore the pragmatic v2.9 service credential. It is
created manually and stored in a CI secret. Future workload identity federation
can replace it without changing the canonical scope and policy model.
The server assigns the client ID. No client identity or callback is hard-coded
in ZPan.
## 4. Stable Upgrade Boundary
Administrators can inspect dynamically registered applications in the existing
authentication settings. This first version does not add application approval:
registration is immediately usable, but user consent is still mandatory before
workspace access is granted. System/reference clients are not presented as
external registered applications.
ZPan separates four concepts:
## Consent and Workspace Binding
| Concept | Responsibility |
|---------|----------------|
| Credential adapter | Validate OAuth, API key, or future Agent JWT |
| Principal | Identify the authorizing user, credential actor, and bound workspace |
| Scope and policy authorization | Intersect credential scopes with current workspace authority |
| Use case | Perform the file operation without knowing the credential protocol |
Authorization code + PKCE creates a user-controlled subject grant. The consent
page resolves the registered client record and displays its real name, callback,
requested scopes, ZPan instance, selected workspace, and grant lifetime.
Conceptually, an Agent-facing principal contains:
Each consent is bound to:
```ts
type AgentPrincipal = {
kind: 'delegated-user' | 'service' | 'agent'
userId: string
orgId: string
scopes: ReadonlySet<Scope>
actor: {
type: 'agent_oauth' | 'api_key' | 'agent'
id: string
}
}
```
- the signed-in user;
- the dynamically registered client;
- exactly one workspace;
- the approved ZPan resource scopes.
The exact TypeScript representation may remain a discriminated union, but
routes must authorize scopes rather than require a concrete `kind`.
Credential-specific fields remain available for diagnostics and revocation;
they do not select business behavior.
The request cannot replace that workspace with a query or body field. Team
membership and role checks still apply. Revoking a consent removes its access
tokens, revokes its refresh tokens, and deletes the consent. The Agent Access
page lists the real client name and workspace for every current-user grant.
The versioned ZPan Agent Skill is published under `skills/zpan` and summarized
in [ZPan Agent Skill](../agent-skill.md). It consumes this authorization model
through Restish profiles instead of adding a second credential or upload
protocol.
## External Resource Token Flow
This boundary deliberately avoids two migration traps:
FlareAuth-style controllers use three credentials with separate purposes:
- File routes must not treat an OAuth bearer as an unrestricted browser user.
- Agent API keys must not become ZPan's proprietary Agent identity,
registration, signing, or capability-grant protocol.
1. A user-approved ZPan subject token represents the connected account.
2. A JWT bearer assertion identifies the Agent/controller actor and mints a
short-lived actor token.
3. OAuth token exchange combines subject and actor tokens for the exact ZPan
`/api` audience and requested scopes.
With this boundary, adopting Agent Auth later adds a verifier, persistence,
approval UI, and management UI. It does not change operation IDs, the unified
OpenAPI document, Skill/plugin workflows, workspace authorization, or file use
cases.
The exchanged access token is a JWT containing the user, workspace,
`zpan_actor`, delegated actor (`act`), audience, scopes, client ID, expiry, and
JTI. API requests use `Authorization: DPoP` plus a proof bound to the method,
URL, access token, and Agent key. ZPan verifies issuer, audience, signature,
expiry, scopes, DPoP proof, and JTI revocation.
## 5. System-Managed OAuth Client
Revoking an exchanged JWT stores its JTI until token expiry. The resource API
rejects revoked tokens. Opaque-token compatibility and fixed-client grant
assertions are intentionally not part of this path.
Create a built-in public native application such as `zpan-agent`.
## Scope Model
Properties:
Resource scopes use stable `<resource>:<action>` names. The external Agent scope
catalog includes:
- system-managed and not editable/deletable
- public client; no client secret
- authorization code grant with PKCE
- loopback redirect URIs `http://localhost:8484/callback` and
`http://127.0.0.1:8484/callback`
- refresh-token support through `offline_access`
- Agent scopes only
Dynamic client registration is not required in v2.9. One first-party client is
enough for the versioned ZPan Skill and Restish integration.
The authorization server publishes discovery metadata. Better Auth OAuth
Provider 1.6.x mounts the runtime endpoints below the Better Auth base path:
| Endpoint | Path |
|----------|------|
| Authorization | `/api/auth/oauth2/authorize` |
| Token and refresh | `/api/auth/oauth2/token` |
| Revocation | `/api/auth/oauth2/revoke` |
| Introspection | `/api/auth/oauth2/introspect` |
| UserInfo | `/api/auth/oauth2/userinfo` |
| Consent | `/api/auth/oauth2/consent` |
| Continue login flow | `/api/auth/oauth2/continue` |
Because Better Auth is mounted at `/api/auth`, ZPan forwards the required
well-known authorization-server and OIDC metadata at root locations and also
publishes protected-resource metadata for `/api`.
## 6. Workspace Grant
OAuth scopes describe allowed operation classes, but a ZPan grant also needs a
resource boundary: exactly one workspace.
The consent record binds:
- authorization/grant ID
- user ID
- OAuth client ID
- workspace `orgId`
- approved scopes
- created, expiry, revoked, and last-used state
Access/refresh tokens resolve to that grant. The API does not derive workspace
from the user's mutable active-organization session.
Effective authorization is:
```text
credential is valid
AND grant/key allows the requested action
AND request targets the bound workspace
AND authorizing user still has the required workspace role
```
For a team workspace, relevant requests recheck current membership and role.
Removing the user or reducing their role immediately reduces Agent access.
For a personal workspace, authorization verifies that the organization is the
authorizing user's personal organization. The current API-key branch in
`requirePermission` lacks this personal-ownership fallback and must add it.
Request bodies and query parameters cannot override the credential's workspace.
A mismatch is `403`, never a fallback to another active or personal workspace.
## 7. Scope Model
ZPan defines one canonical authorization vocabulary for scoped credentials.
OAuth grants, Agent API keys, and future Agent credentials resolve to the same
scope set. A browser cookie is a first-party, unbounded credential: it does not
need a role-to-scope mapping, but it still passes the route's declared
workspace, minimum-role, ownership, and resource policies. There is no
separately named permission vocabulary and no `Scope -> Permission` mapping.
Scope names follow:
```text
<resource>:<action>
```
Rules:
- lowercase ASCII only;
- plural domain resource names such as `objects`, `shares`, and `tasks`;
- a small shared action vocabulary such as `read`, `create`, `update`, and
`delete`;
- business operations rather than HTTP methods;
- no wildcard semantics or access implied by string prefixes;
- no `zpan:` prefix, because token issuer and audience already identify the
ZPan API;
- published scope meanings are stable and must never silently broaden.
Initial Agent-grantable scopes are:
| Scope | Intended operations |
|-------|---------------------|
| Scope | Authority |
|---|---|
| `objects:read` | List, inspect, and download objects |
| `objects:create` | Create folders, upload drafts, upload-part signatures, and complete uploads |
| `objects:update` | Rename, move, and copy objects within the authorized workspace |
| `objects:create` | Create folders and direct-upload sessions |
| `objects:update` | Rename, move, and copy objects |
| `objects:delete` | Soft-delete objects |
| `shares:read` | List and inspect shares |
| `shares:read` | Inspect shares |
| `shares:create` | Create public shares |
| `shares:delete` | Revoke shares |
| `quota:read` | Inspect workspace quota |
| `storage-usage:read` | Inspect workspace storage usage |
| `tasks:read` | Inspect task state |
Protocol scopes such as `openid` and `offline_access` retain their standard
OAuth/OIDC meaning. They are not ZPan route permissions.
Every protected route declares the minimum scopes required to perform its
operation. It does not enumerate the roles, presets, credential types, broad
scopes, or Agent classes allowed to call it. For example:
```ts
auth: {
allOf: ['objects:delete'],
workspace: 'required',
}
```
Scope authorization is necessary but not sufficient. Workspace membership,
resource ownership, resource state, quota, and other request-specific
constraints remain explicit policy checks.
The consent and API-key UIs can present Reader, File manager, and Publisher
shortcuts. A shortcut expands to an explicit set of scopes; it is not itself a
scope, and routes never reference its name. Destructive and public-sharing
scopes remain separately selectable.
No Agent-grantable scope implies admin, billing, entitlement, membership,
credential management, WebDAV, image-hosting configuration, or downloader
registration. Those protected APIs still use the same route scope mechanism but
are excluded from the Agent credential grant policy.
## 8. Authorization Code + PKCE
This is the default Restish flow:
1. Skill identifies and confirms the ZPan origin and Restish API name.
2. Skill requires Restish v2.
3. `restish api connect` discovers `/api/openapi.json` and applies its
server-published OAuth binding.
4. The first safe Agent operation starts browser authorization.
5. Restish creates a PKCE verifier/challenge and listens on its loopback
callback.
6. User signs in, selects one workspace, reviews scopes, and approves or denies.
7. Restish exchanges the authorization code and caches the tokens.
8. Later commands refresh tokens without exposing them to the Agent response.
The consent page displays the Agent client, instance hostname, workspace,
requested scopes, destructive/public side effects, and grant lifetime.
Restish's `--rsh-no-browser` may be used when a browser cannot be opened but the
authorization-code callback can still be completed manually.
## 9. Deferred Agent Device Authorization
Standard Agent device authorization is a v2.9.x follow-up. It must issue tokens
for the same workspace grant and scope model as authorization code + PKCE, not a
broad Better Auth session token. The existing Better Auth device plugin remains
restricted to the legacy `zpan-cli` downloader bootstrap until that follow-up.
## 10. Agent API-Key Issuance
Manual API-key creation is the initial CI path:
1. User opens Agent Access settings.
2. User selects a workspace.
3. User names the Agent or environment.
4. User selects permissions and expiry.
5. Server verifies current authority and creates an `agent` API key.
6. The plaintext key is shown once.
New Agent keys never use `scope.mode = "user-workspaces"`. One key authorizes one
workspace. Expiry is required, defaults to 90 days, and cannot exceed one year.
Use one key per CI environment. Personal workspace owners and team
owners/admins can manage Agent keys; team editors cannot issue credentials.
The UI lists name, workspace, permission summary, creation, expiry, last use,
and status. Revocation is immediate. Only active keys can rotate. Rotation
creates a new key and never reveals or mutates the old secret; expired and
revoked keys are terminal, so the user creates a new key instead.
## 11. OpenAPI and Restish v2 Binding
ZPan publishes one unified `/api/openapi.json`. It defines:
- relative server URL for Agent API routes
- OAuth authorization-code security scheme with Agent scopes
- Bearer alternative for Agent API keys
- stable operation IDs and structured errors
- document-level Restish v2 `x-cli-config` profiles
Conceptual configuration:
```yaml
components:
securitySchemes:
agentOAuth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: /api/auth/oauth2/authorize
tokenUrl: /api/auth/oauth2/token
scopes:
objects:read: Read files and folders
objects:create: Upload files and create folders
objects:update: Rename, move, and copy files and folders
objects:delete: Delete files and folders
agentApiKey:
type: http
scheme: bearer
x-cli-config:
profiles:
default:
credentials:
agentOAuth2:
params:
client_id: zpan-agent
scopes: openid offline_access objects:read quota:read
redirect_path: /callback
file-manager:
credentials:
agentOAuth2:
params:
client_id: zpan-agent
scopes: openid offline_access objects:read objects:create objects:update objects:delete quota:read tasks:read
redirect_path: /callback
```
The real document also provides a Publisher shortcut. Reader is the default, so
connecting the API does not silently request write or share permission. These
profile names only expand to explicit scopes; routes never reference them.
A separate environment-backed profile selects `agentApiKey` for CI. No Agent
device-code profile is published in v2.9.
The OpenAPI document never contains credentials or configures an executable
credential helper. Skill instructions select a named Restish profile rather
than assuming OAuth or a particular environment-variable name. This keeps
operation workflows unchanged if a future local profile uses an Agent Auth
signer.
All formal API operations remain visible to Restish CLI generation. Declared
scopes and dynamic policy decide whether a credential may call them. Only
browser callbacks and internal-only endpoints are hidden from CLI generation.
MCP additionally ignores authentication, administration, and credential
management operations and keeps write tools disabled by default. ZPan does not
maintain a second Agent operation allowlist.
## 12. Restish Upload Plugin
`restish-zpan` is a Restish v2 command plugin shipped from this repository. It
contributes `restish zpan-upload` and is installed with:
```sh
restish plugin install saltbo/zpan zpan
```
The plugin uses Restish delegated HTTP for ZPan draft, part re-sign, complete,
and abort operations, preserving the selected profile, OAuth/API-key
authentication, TLS, and normalized output. With Restish v2.3 command plugins,
the host profile is selected through `RSH_PROFILE` while the plugin's matching
`--profile` selects spec validation and checkpoint identity. It streams local file sections
directly to presigned S3 URLs with bounded concurrency, retry, ETag capture,
resume checkpoints, and idempotent completion.
The plugin never asks Restish for authentication secrets. Checkpoints contain
only safe API/profile identity, upload session and file identity, and completed
part/ETag state; they contain no token, cookie, API key, or presigned URL. The
Skill invokes this command and never implements multipart state itself.
## 13. Route Authorization
Both credential types enter a shared Agent authorization boundary.
For OAuth:
1. validate/introspect the access token;
2. require the built-in Agent client ID and the route's required scopes;
3. resolve user and bound workspace grant;
4. recheck current workspace authority.
For API keys:
1. verify key, expiry, revocation, rate limit, and owner status;
2. require `configId = "agent"` and the route's required scopes;
3. resolve bound workspace metadata;
4. recheck current workspace authority.
Both then invoke the same use case with the bound `orgId` and a typed audit
actor. Routes use shared permission middleware instead of session-only or
principal-specific checks. The shared middleware accepts the internal principal
contract, so tests for protected operations do not need to know how the
principal authenticated.
Special considerations:
- A presigned upload URL may remain usable briefly after credential revocation
because S3 validates the signature independently. Keep presigned lifetimes
short.
- Upload completion and new part presigning always reauthorize.
- Issuing a new download URL requires object-read permission.
- Listing and task responses remain workspace-filtered and paginated.
- Share creation requires `shares:create` even when the Agent can read the
object.
## 14. Audit and Management
Audit records distinguish resource ownership from the actor that initiated the
operation. OAuth actions record an `agent_oauth` actor with grant/client
attribution. API-key actions retain `api_key` with the key ID as `actorRef`.
Both record the authorizing user, workspace, action, target, outcome, and safe
metadata. A future Agent Auth adapter records `agent` with its Agent ID while
retaining the delegated user as resource owner.
Agent Access settings show two sections:
- delegated OAuth grants, with client, workspace, scopes, last use, and revoke;
- service API keys, with name, workspace, permissions, expiry, last use, and
revoke/rotate for active keys.
Revoking a delegated grant invalidates its refresh tokens and prevents new
access tokens. Short access-token lifetime bounds any validation-cache delay.
`restish api auth logout` clears local cached tokens; server-side revoke remains
available when a device is lost.
Credentials are never recorded or redisplayed.
## 15. Current Code Gaps
- ZPan has bearer sessions and device authorization but is not yet an OAuth
authorization server with Agent resource scopes and workspace grants.
- Legacy device authorization validates only `zpan-cli` with
`downloader:register` and yields only a single-use downloader bootstrap
credential.
- `shared/api-key-templates.ts` lacks an Agent template.
- `server/http/objects.ts` rejects ordinary API-key principals.
- authenticated shares, quota, trash, and several task routes require a user
session instead of a permission.
- the current principal model and `requireAuth` helper encourage routes to
branch on identity kind; all protected routes need shared scope declarations
and a protocol-neutral authorization boundary.
- API-key authorization needs the personal-workspace ownership check.
- the unified OpenAPI document lacks operation security and CLI/MCP annotations;
- the current upload contract lacks explicit part descriptors, robust re-sign,
expiry, idempotent completion, and a Restish command plugin.
These authorization-boundary changes require integration tests for OAuth and
API-key success, missing scope/permission, wrong workspace, wrong client, role
reduction, expiry, revocation, and personal/team spaces.
## 16. Agent Auth Protocol Compatibility
The [Agent Auth Protocol](https://agentauthprotocol.com/) is a strong long-term
fit because it gives every Agent a cryptographic identity, scoped capability
grants, an independent lifecycle, and per-Agent audit attribution. The
[Better Auth Agent Auth plugin](https://better-auth.com/docs/plugins/agent-auth)
also provides discovery, device/CIBA approval, short-lived signed JWTs, replay
protection, OpenAPI/MCP adapters, and lifecycle events.
It is not the required v2.9 production path:
- the protocol is currently `v1.0-draft`, and the plugin documentation marks
the implementation as unstable;
- Restish does not natively implement Agent Auth request signing;
- production Cloudflare Workers need distributed JTI replay storage rather than
the plugin's default in-memory cache;
- custom REST `location` handlers must validate grants and constraints in the
shared authorization layer;
- converting the full ZPan OpenAPI document into capabilities would expose too
much surface.
The intended future adapter is:
```text
Agent Auth JWT
-> verify signature, audience, expiry, and JTI
-> resolve delegated user and approved workspace
-> normalize capability grants to the canonical Scope set
-> create protocol-neutral principal and `agent` audit actor
-> run existing scope and policy middleware and use case
```
The effective permission remains:
```text
Agent Auth capability grant
AND authorizing user's current workspace role
AND request targets the approved workspace
AND resource-specific policy allows the operation
```
Expected change surface:
| Remains unchanged | Added for Agent Auth |
|-------------------|----------------------|
| Unified OpenAPI and operation IDs | Agent/host/grant/approval persistence |
| ZPan Skill and upload-plugin workflows | Agent JWT credential adapter |
| File, share, quota, and task use cases | Approval and Agent-management UI |
| Route scope requirements and workspace policies | Distributed JTI replay storage |
| Presigned direct-to-S3 upload sequence | Restish signing profile/helper |
Agent Auth does not replace role, quota, storage, share, or ownership checks.
Autonomous/anonymous Agent registration and later claim are outside the current
product boundary; an initial integration supports delegated Agents only.
Restish remains the operation client. Until it supports Agent Auth natively, a
future profile may use its
[external-tool authentication](https://rest.sh/docs/recipes/use-external-tool-auth/)
to invoke the official Agent Auth client or a minimal reviewed signer. This is
an authentication adapter, not a standalone ZPan CLI. The unified OpenAPI
operations and Skill workflows remain unchanged.
Before promotion from preview to the default interactive flow, require:
- a maintained Restish signing integration or native Agent Auth support;
- distributed JTI replay protection on Workers and an equivalent Node path;
- cross-runtime tests for registration, approval, execution, replay, revoke,
role reduction, and workspace isolation;
- an explicit Agent-grantable scope catalog rather than automatic authorization
for every operation in the unified OpenAPI document;
- acceptable upstream protocol and package stability.
## 17. Rejected Alternatives
### Device Approval Mints an API Key
Rejected because device approval must eventually issue the same delegated OAuth
grant as authorization code + PKCE. Minting an API key would replace that
short-lived and refreshable lifecycle with a proprietary exchange.
### API Key for Every Agent
Rejected because interactive user delegation benefits from consent, short access
tokens, refresh-token revocation, and client identity. API keys remain
appropriate for CI and unattended services.
### OAuth for CI by Pretending a User Is Present
Rejected because unattended automation should not depend on a human refresh
grant. Use a scoped API key until workload identity federation is available.
### Agent Auth as the Only v2.9 Credential
Deferred because the protocol and current plugin remain unstable and Restish
needs an external signer. The compatibility boundary is included now;
production adoption can follow without making v2.9 depend on a draft protocol.
### Browser Cookies
Rejected because they are broad, mutable user-session credentials and unsafe to
copy into Agent environments.
### One Credential Across All User Workspaces
Rejected because it makes compromise impact, audit interpretation, role changes,
and revocation unnecessarily broad.
### Anonymous Upload and Claim
Deferred outside v2.9. File storage normally implies persistence and an
accountable quota owner. Revisit only if ZPan deliberately builds a
try-before-login artifact-delivery product.
## 18. Future Evolution
- Better Auth Agent Auth compatibility adapter, initially behind a feature flag
- Delegated Agent approval and per-Agent revoke/management UI
- Distributed JTI and Agent-key cache storage for Cloudflare Workers
- Workload identity federation for supported CI providers
- Dynamic client registration for trusted third-party Agent platforms
- Standard Agent device authorization using the same workspace grant and scopes
- Rich Authorization Requests if third-party clients need standardized
workspace selection in the authorization request
- HTTP Message Signatures / Web Bot Auth for additional Agent-operator
attribution, never workspace authorization
Administrative, billing, credential-management, WebDAV, downloader bootstrap,
and purge authority are not grantable through this catalog.
OAuth is a credential adapter, not a business-logic fork. Middleware resolves a
protocol-neutral principal, bound workspace, scope set, and audit actor before
calling the same file use cases used by other authenticated clients.
## Self-Describing Direct Upload
File bytes continue to bypass ZPan and go directly to S3-compatible storage.
The create-object response is the upload workflow contract; an Agent does not
need a plugin or skill to infer hidden follow-up steps.
The response includes:
- upload ID and object draft;
- ordered part descriptors with part number, byte offset, byte length, method,
presigned URL, and required headers;
- a `workflow` object describing the upload request;
- the exact complete, re-presign, and abort operation IDs, methods, and paths;
- instructions to preserve each upload response ETag and submit
`{ partNumber, etag }` to completion.
An Agent follows this generic sequence:
1. Call `createObject` with file name, size, type, and workspace context.
2. Split the local file according to each returned `offset` and `length`.
3. `PUT` each byte range to its returned presigned URL and retain the response
ETag.
4. If a URL expires, call the returned re-presign operation for only the
unfinished part numbers.
5. Call the returned complete operation with all part numbers and ETags.
6. On an intentional cancellation, call the returned abort operation.
Presigned URLs are bearer capabilities with short lifetimes. They must not be
logged, cached in checkpoints, or sent through the controller. Completion and
re-presigning re-enter ZPan authorization and workspace checks.
## Compatibility Boundary
The legacy `zpan-cli` device flow remains limited to downloader registration.
Ordinary human-created API keys remain available for their existing product
uses, but there is no Agent API-key template or Agent key management UI.
Future client-registration approval can be added around dynamically registered
client records without changing resource discovery, consent, token exchange,
OpenAPI, upload responses, or file use cases.
## Acceptance
The integration is complete when a generic FlareAuth/Restish controller can:
1. discover ZPan from `/api`;
2. dynamically register and appear in administrator settings;
3. create a user-visible authorization request;
4. obtain a DPoP resource token after consent;
5. discover file operations from OpenAPI and upload workflows from Arazzo;
6. upload bytes and complete the upload using the Arazzo and returned runtime
workflow data;
7. list, read, and rename the resulting object;
8. lose access after grant or JWT revocation.
-47
View File
@@ -1,47 +0,0 @@
# Restish ZPan Upload Plugin
`restish-zpan` contributes the `restish zpan-upload` command. It uses Restish
profiles for ZPan API calls and streams file bytes directly from disk to
presigned storage URLs.
The companion [ZPan Agent Skill](agent-skill.md) selects when to use generated
Restish commands and when to invoke this plugin. The Skill does not implement
multipart upload behavior itself.
## Install
Restish plugins are trusted local executable code. Agents must explain that
trust boundary and get explicit user approval for the `saltbo/zpan` source
before installing:
```bash
restish plugin install saltbo/zpan zpan
```
## Usage
```bash
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager ./photo.jpg
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent albums ./photo.jpg cover.jpg
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --resume ./large.bin
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --abort ./large.bin
```
The plugin validates the connected ZPan OpenAPI operations before uploading:
`createObject`, `presignObjectUploadParts`, `completeObjectUpload`, and
`abortObjectUpload`.
Control-plane calls are delegated to Restish so host configuration, auth, TLS,
cache policy, and output formatting stay host-owned. Presigned storage PUTs use
native Go HTTP because file bytes and presigned URLs must not cross the plugin
CBOR channel.
Local checkpoints are written with mode `0600` under the user cache directory.
They contain API/profile identity, source file identity, destination identity,
the ZPan object/session IDs, part size/count, and completed part ETags. They do
not contain credentials, cookies, presigned URLs, or file bytes.
Restish v2.3 command plugins receive the delegated HTTP profile through
`RSH_PROFILE`; use it for the host credential selection. The plugin's
`--profile` value is separately used for spec validation and checkpoint
identity.
+108 -371
View File
@@ -1,409 +1,146 @@
# v2.9 — Agent Access
# v2.9 — External Agent Access
Make ZPan operable by coding agents, scripts, and CI without maintaining a
separate ZPan CLI. v2.9 publishes a stable Agent-facing API contract and teaches
agents to use it through Restish v2 plus a ZPan Skill.
## Goal
The authentication decision follows the current FlareAuth + Restish v2 pattern:
- **Interactive Agent:** OAuth authorization code + PKCE.
- **Unattended automation:** workspace-scoped Agent API key.
Standard Agent device authorization is deferred to v2.9.x. The existing
`zpan-cli` device flow remains only as a compatibility bootstrap for downloader
registration. Its bearer is a single-use downloader registration credential, not
a browser session or general Agent credential.
OAuth grants and API keys are separate because they represent different actors:
delegated user access versus a service credential. An Agent never receives a
browser cookie or an unrestricted user session. Anonymous uploads, provisional
workspaces, and claim flows are not part of v2.9.
The file API does not depend on either credential format. Both flows resolve to
a protocol-neutral principal and the same scope and workspace-policy checks. This is
an explicit compatibility boundary for adding Agent Auth Protocol later without
redesigning the OpenAPI operations, Skill, upload plugin, or file use cases.
The detailed model is in
[Agent Authentication and Authorization](../design/agent-authentication.md).
The published workflow package is [ZPan Agent Skill](../agent-skill.md), with
the installable Skill source under `skills/zpan`.
Make ZPan a self-describing OAuth resource that generic Agent controllers can
discover and operate without a ZPan-specific skill, Restish profile, or upload
plugin.
## Product Boundary
- **Community** gets the unified OpenAPI contract, interactive OAuth, Agent API
keys, Restish setup, the ZPan upload plugin, and the ZPan Skill.
- **Pro / Business** may raise API and automation limits, but paid tiers do not
gate ordinary authenticated file automation.
- Desktop sync clients do not use Restish as their engine. They continue to use
sync-specific APIs and OS integrations in their own projects.
ZPan owns:
## Why Restish + Skill
- OAuth protected-resource and authorization-server metadata;
- dynamic OAuth client registration;
- administrator visibility of registered applications;
- user consent and workspace-bound grants;
- JWT bearer actor authentication and OAuth token exchange;
- DPoP-bound resource tokens and revocation;
- scope-aware file APIs, Arazzo workflows, and structured direct-upload
instructions.
Restish v2 already generates commands from OpenAPI, supports OAuth and API-key
profiles, caches and refreshes OAuth tokens, emits machine-readable output, and
provides retries and pagination. Its official MCP plugin can expose an API as
tools, with write operations disabled by default.
ZPan therefore owns:
- one unified OpenAPI document with operation-level scope declarations
- server-published Restish v2 authentication bindings
- a `restish-zpan` command plugin for streaming and resumable multipart uploads
- a ZPan Skill that explains safe workflows and selects the right Restish surface
- the server-side authentication and authorization contract
The plugin is a narrow Restish extension, not a standalone ZPan CLI. Restish
continues to own command parsing for generated API operations, profiles,
credential storage, authentication, and output formatting.
Baseline Restish version: **v2.3 or later**. v2.3 is selected because it includes
the current plugin system, OAuth flows, OpenAPI credential binding, and official
MCP integration. Restish's embedding API can build a branded CLI, but that
option is deliberately not used.
References:
- [OpenAPI CLI integration](https://rest.sh/docs/reference/openapi-cli-integration/)
- [Authentication](https://rest.sh/docs/guides/authentication/)
- [Automation](https://rest.sh/docs/guides/automation/)
- [MCP plugin](https://rest.sh/docs/plugins/mcp/)
- [Command plugins](https://rest.sh/docs/plugins/command-plugins/)
The external controller owns Agent identity, approval of Agent access, delegated
credential injection, and tool orchestration. ZPan does not ship a fixed Agent
client or an Agent API-key product.
## Deliverables
### Protocol-Neutral Authorization Boundary
### Discovery and Dynamic Registration
Authentication adapters resolve credentials into an internal principal before
any file, share, quota, or task authorization:
- Publish the exact `/api` resource audience.
- Link `/api/openapi.json` from the resource response.
- Publish protected-resource and authorization-server metadata.
- Advertise and accept dynamic client registration with PKCE.
- Show dynamically registered applications and their metadata to
administrators.
- Do not require application approval in the first release.
```text
Browser session ────┐
OAuth access token ─┼─> principal + actor + granted scopes ─> scope + policy check
Agent API key ──────┘
```
### Consent and Grants
Every protected route declares the minimum scopes required for its operation,
such as `objects:read`, `objects:create`, and `shares:create`. Routes do not
declare which credential types, roles, presets, or Agent classes may call them.
Authentication adapters normalize browser sessions, OAuth tokens, API keys, and
future Agent credentials into the same authorization context. Each adapter
supplies:
- Resolve the dynamic client name and callback at consent time.
- Bind every consent to one user, client, workspace, and explicit scope set.
- Let the user switch among accessible workspaces before approval.
- List grants with their actual registered application names.
- Revoke the selected consent plus its access and refresh token family.
- the authorizing `userId`
- the applicable workspace boundary, including one bound `orgId` for Agent
grants and Agent API keys
- the granted scopes
- a typed actor for audit attribution
- credential expiry and revocation state
### External Resource Tokens
Effective permission is always the intersection of granted scopes, the
authorizing user's current workspace role, and resource-specific rules. Adding
a future `agent-jwt` adapter must therefore require no changes to route scope
requirements, file use cases, or operation IDs.
- Accept JWT bearer assertions from registered clients with JWKS.
- Mint short-lived actor tokens.
- Exchange a user subject token and actor token for the exact ZPan API audience.
- Require DPoP proofs for token exchange and resource requests.
- Include workspace and delegated actor claims in resource JWTs.
- Support JTI-based access-token revocation.
### Unified OpenAPI
Publish only `/api/openapi.json`. Every formal operation remains available to
Restish command generation; declared scopes and dynamic policy determine
whether a credential may invoke it. Browser callbacks and internal-only
endpoints are excluded from CLI generation. MCP additionally ignores
authentication, administration, and credential-management operations and keeps
write tools disabled by default.
- Keep one OpenAPI document for browser, API, and Agent consumers.
- Publish stable operation IDs and `x-zpan-auth` resource-scope requirements.
- Keep administrative routes protected by their normal security declarations.
- Do not publish Restish profiles, client IDs, secrets, environment-variable
bindings, or executable credential helpers.
- Publish a public OAuth resource-scope catalog with standard OAuth security
declarations while keeping Agent operations bound only by `x-zpan-auth`, so
no built-in client or credential profile is selected.
Every protected operation declares its scope and workspace/role policy at the
route. The same declaration drives runtime enforcement and OpenAPI security, so
there is no second Agent operation allowlist to drift. Every machine-facing
operation needs a stable `operationId`, bounded pagination, documented
idempotency, structured errors, and suitable examples.
### Discoverable API Workflows
The document declares both allowed authentication alternatives:
- Publish an Arazzo 1.1 JSON document for prepare, re-presign, complete, and
abort upload workflows.
- Advertise it from the resource URL with a typed `describedby` Link and from
OpenAPI through `externalDocs`.
- Reference stable OpenAPI operation IDs so a controller can resolve required
scopes and invoke protected steps through its normal OpenAPI client.
- Keep the response-provided upload descriptor authoritative for presigned
storage PUTs because their absolute URLs are generated at runtime.
- OAuth 2.0 authorization code with PKCE and Agent resource scopes
- HTTP Bearer authentication for a manually issued Agent API key
### Self-Describing Direct Upload
It also publishes Restish v2 Reader, File manager, and Publisher convenience
profiles for the built-in public native client. These names expand to explicit
scope sets and are never referenced by routes. Reader is the default; broader
profiles request their scopes explicitly. No secret is embedded in the
document.
`createObject` returns everything a generic Agent needs:
The existing document currently exposes roughly 145 operation IDs without
operation security or `x-mcp-ignore` annotations. v2.9 adds those declarations
without creating `/api/openapi.agent.json`.
- upload and object identifiers;
- part number, byte offset, byte length, HTTP method, presigned URL, and required
headers for every part;
- explicit instructions to retain each response ETag;
- complete, re-presign, and abort operation IDs, methods, and paths.
### ZPan Skill
The Agent uploads bytes directly to S3-compatible storage, then completes the
draft with part numbers and ETags. Re-presigning and completion reauthorize
against ZPan. Presigned URLs and file bytes never need to transit the external
controller.
Publish a versioned Skill that:
### Authorization
1. detects the ZPan origin;
2. verifies Restish v2 and connects `/api/openapi.json` with
`--replace --yes`;
3. selects the least-privilege Reader, File manager, or Publisher profile;
4. syncs a previously connected API before use;
5. triggers browser OAuth on the first safe request when a local callback is
available;
6. verifies and, after explicit trust confirmation, installs the
`restish-zpan` plugin;
7. uses an environment-backed Agent API-key profile for CI;
8. sends every local file upload through `restish zpan-upload`;
9. confirms target workspace, destructive operations, overwrite behavior, and
public sharing;
10. returns object IDs, URLs, quota effects, and task state in a compact
machine-readable result.
The grantable resource scopes cover object read/create/update/delete, share
read/create/delete, quota read, storage-usage read, and task read. Purge,
administration, billing, credential management, WebDAV configuration, and
downloader registration remain excluded.
The Skill never asks the user to paste a bearer token. Restish owns OAuth token
storage, refresh, logout, and redacted authentication diagnostics. Skill
workflows refer to a selected ZPan Restish profile rather than assuming a
specific environment variable or credential type, so a future profile may use
an external Agent Auth signer without changing the file-operation instructions.
OAuth resolves to the same protocol-neutral principal and route policies used
by the rest of ZPan. Business use cases do not branch on a particular Agent
controller or client ID.
The repository ships the Skill as `skills/zpan` with routed references for
setup, file operations, uploads, CI, MCP, and acceptance evidence. The
user-facing setup guide is [docs/agent-skill.md](../agent-skill.md).
## Removed Compatibility Surfaces
### Restish Upload Plugin
- fixed, system-managed Agent OAuth client;
- Agent API-key template, endpoints, settings forms, and tests;
- OpenAPI `x-cli-config` profiles;
- `restish-zpan` command plugin and release artifact;
- repository-hosted ZPan Agent skill.
Ship `restish-zpan` from this repository and install it with:
```sh
restish plugin install saltbo/zpan zpan
```
It contributes `restish zpan-upload`. The plugin uses Restish delegated HTTP
for ZPan draft, re-sign, complete, and abort operations, preserving the selected
profile, OAuth/API-key authentication, TLS, and output behavior. With Restish
v2.3 command plugins, the host profile is selected through `RSH_PROFILE` while
the plugin's matching `--profile` selects spec validation and checkpoint
identity. It streams
local file parts directly to presigned S3 URLs with bounded concurrency, retry,
ETag capture, resume checkpoints, and idempotent completion. File bytes and
presigned URLs never pass through the Agent context or Restish's plugin CBOR
channel.
The first release supports single and multipart files, re-signing expired
parts, interrupted resume, and explicit abort. Checkpoints contain no
credentials or presigned URLs and are removed after success. The Skill invokes
the plugin; it does not implement the upload state machine itself.
The official `restish-mcp` plugin is an optional transport for ordinary API
operations, not the upload implementation. Its default read-only mode is useful
for browsing. Keep the default recipe read-only:
```sh
restish plugin install rest-sh/restish mcp
restish mcp serve zpan --operations listObjects,getObject,listShares,getUserQuota,getStorageUsage
```
Enable write tools only with an explicit reviewed operation allowlist. Do not
allow upload draft, part signing, completion, or abort operation IDs through
MCP.
Keep MCP results bounded; the plugin's default result limit is 16 KiB. Object
contents continue to move through presigned URLs, never through an MCP result.
### Interactive OAuth
Create a system-managed public native client, for example `zpan-agent`, with:
- authorization code + PKCE
- Restish v2.3 loopback callbacks `http://localhost:8484/callback` and
`http://127.0.0.1:8484/callback`
- refresh-token support through `offline_access`
- only Agent API scopes
Better Auth OAuth Provider 1.6.x serves the flow below the auth base path:
`/api/auth/oauth2/authorize`, `/api/auth/oauth2/token`,
`/api/auth/oauth2/revoke`, `/api/auth/oauth2/introspect`, and
`/api/auth/oauth2/userinfo`. ZPan additionally forwards required root
well-known metadata for the `/api/auth` issuer and publishes protected-resource
metadata for `/api`.
The default Restish profile uses authorization code + PKCE. After
`restish api connect`, the first safe Agent API request opens browser consent;
Restish caches and refreshes the resulting tokens. `--rsh-no-browser` may be
used when the callback can still be completed manually. Standard Agent device
authorization remains a v2.9.x follow-up.
Consent binds the grant to:
- the signed-in user
- exactly one workspace
- requested resource scopes
- the user's current workspace role
- the OAuth client and expiry/revocation state
Access tokens are short-lived. Refresh tokens remain bounded by that grant.
Changing team membership or revoking the grant removes access independently of
the user's browser sessions.
### Agent API Keys
Add an `agent` API-key template for CI and other unattended automation.
Defaults:
- exactly one workspace
- least-privilege permissions selected by the user
- explicit name, expiry, last-used time, and revocation
- personal owners and team owners/admins manage keys; editors cannot issue credentials
- separate keys for separate Agents and environments
- no admin, billing, membership, entitlement, or credential-management access
- team membership and role rechecked at authorization boundaries
- rate and storage limits enforced server-side
The plaintext key is returned once and stored in a CI secret or another
non-interactive secret store.
Only active keys can be rotated. Expired and revoked keys are terminal; create
a new key when a new lifetime or credential is required.
### Scopes and Presets
The server has one canonical authorization vocabulary. OAuth grants, API keys,
browser-session roles, and future Agent credentials all produce a set of the
same scopes; there is no separate `Scope -> Permission` mapping.
Scope names use the stable, lowercase `<resource>:<action>` form. Resources are
plural domain nouns and actions come from a small shared vocabulary such as
`read`, `create`, `update`, and `delete`. Scope names describe business
authority, not HTTP methods. They do not include a redundant `zpan:` prefix:
the token issuer and audience already identify the ZPan API.
Initial Agent-grantable scopes are:
| Scope | Intended operations |
|-------|---------------------|
| `objects:read` | List, inspect, and download objects |
| `objects:create` | Upload files and create folders |
| `objects:update` | Rename, move, and copy objects |
| `objects:delete` | Soft-delete objects |
| `shares:read` | List and inspect shares |
| `shares:create` | Create public shares |
| `shares:delete` | Revoke shares |
| `quota:read` | Inspect workspace quota |
| `tasks:read` | Inspect task state |
Protocol scopes such as `openid` and `offline_access` retain their standard
OAuth/OIDC meaning. They are not ZPan route permissions.
Each protected route declares its minimum required scopes. A route declares
what authority the operation needs, not a list of broad scopes or caller types
that are allowed to invoke it. Workspace membership, resource ownership,
resource state, quota, and other dynamic constraints remain policy checks after
the scope check.
Reader, File manager, and Publisher are UI/Restish shortcuts that expand to
explicit scope sets. They are not scopes and routes never reference preset names.
Destructive and public-sharing scopes remain separately selectable.
## Current Gaps to Close
- ZPan is not currently an OAuth authorization server for Agent resource
scopes.
- The legacy `zpan-cli` device flow is intentionally limited to the exact
`downloader:register` scope and downloader registration endpoint.
- Object routes currently reject ordinary API-key principals with a blanket
session-only gate.
- Authenticated share and quota routes currently require a user session.
- Protected routes need shared scope middleware that accepts a protocol-neutral
principal instead of branching on `user`, `api-key`, or another credential
kind. Every protected route must declare its minimum scopes.
- API-key templates currently cover image hosting, WebDAV, and remote download,
but not general Agent file management.
- Workspace API-key authorization needs an explicit personal-space ownership
path in addition to team membership checks.
- The unified OpenAPI document lacks operation security and CLI/MCP annotations.
- Upload conflict policy, idempotency, and multipart retry behavior need an
explicit public contract.
The existing `zpan-cli` device authorization remains only for its legacy,
single-use downloader-registration bootstrap.
## Delivery Order
1. Define the canonical scope vocabulary and scope declaration metadata.
2. Refactor all protected routes to authorize protocol-neutral principals,
declare minimum scopes, and emit typed actors rather than require a
particular principal kind.
3. Add manual Agent API keys and make scoped API access work end to end.
4. Add the system-managed public native OAuth client, Agent grants, and
authorization-code + PKCE flow.
5. Stabilize the server multipart protocol.
6. Publish unified OpenAPI security and Restish v2 credential bindings.
7. Ship and release the `restish-zpan` upload command plugin.
8. Ship the ZPan Skill and validate browser OAuth and CI profiles.
9. Add the optional, allowlisted Restish MCP recipe.
API keys come first as the smallest way to prove the resource authorization
boundary. They do not become the interactive login protocol.
## Non-goals for v2.9
- A standalone `zpan` CLI or branded Restish binary
- Standard Agent device authorization before the v2.9.x follow-up
- Anonymous upload, provisional workspace, preview-and-claim, or anonymous
permanent storage
- Desktop sync or filesystem-provider integration
- Agent access to admin, billing, entitlement, membership, or credential
management
- Giving an Agent a browser cookie or unrestricted user session
- Dynamic third-party OAuth client registration
- Making the draft Agent Auth Protocol or its current Better Auth plugin a
required production dependency
- A ZPan-specific Agent identity, key-signing, capability-grant, or claim
protocol that duplicates a future standards-based integration
- Enterprise identity protocols or custom workspace roles
- Server-proxied file bytes
1. Resource and authorization-server discovery.
2. Dynamic client registration and administrator visibility.
3. Workspace consent, grants, and revocation.
4. JWT bearer, token exchange, DPoP, and JWT revocation.
5. Tool-neutral OpenAPI authorization metadata.
6. Discoverable Arazzo upload workflows.
7. Self-describing single/multipart upload responses.
8. Remove fixed-client, Agent API-key, profile, plugin, and skill surfaces.
9. Complete local gates and real FlareAuth acceptance.
## Acceptance Criteria
- A Restish v2 client connects to `/api/openapi.json`, triggers browser
OAuth + PKCE, and reuses/refreshes cached tokens without token copy/paste.
- Consent shows and binds one workspace and the requested scopes.
- A CI job can use a manually created workspace-scoped Agent API key.
- Browser-session, OAuth, and API-key principals pass through the same scope and
workspace-policy authorization.
- Every protected route declares its minimum required scopes; no route
authorizes by credential kind, Agent type, or preset name.
- Agent-facing routes and use cases do not branch on OAuth versus API key, and
their authorization tests can supply a protocol-neutral principal directly.
- Attempts to access another workspace or an ungranted operation return `403`
and produce an audit event.
- Removing the grant/key owner's team membership or reducing their role
immediately removes the corresponding Agent access.
- Expired or revoked credentials cannot complete uploads or issue new download
URLs.
- The unified OpenAPI document derives operation security from route declarations.
- `restish zpan-upload` completes and resumes multipart uploads without exposing
file bytes, credentials, or presigned URLs to the Agent.
- Credentials never enter logs, audit metadata, analytics, share URLs, or MCP
results.
- A controller starting only from the ZPan `/api` URL discovers OpenAPI,
Arazzo, and OAuth metadata.
- Dynamic registration creates a client visible in administrator settings.
- The user sees an authorization request with the real application name,
callback, workspace, scopes, and lifetime.
- After approval, the controller obtains a DPoP resource token for `/api`.
- A generic Agent creates an upload, sends all returned byte ranges, captures
ETags, and completes it without ZPan-specific code.
- The same connection lists, reads, and renames the uploaded file.
- Grant or JWT revocation stops subsequent resource access.
- Lint, type checking, Node tests, Cloudflare tests, and applicable end-to-end
checks pass.
## User Scenarios
## Deferred
**Interactive coding Agent:**
> The Agent connects ZPan through Restish. My browser opens a consent screen
> where I approve one workspace and the requested scopes. Restish stores and
> refreshes the OAuth tokens locally.
**CI release workflow:**
> I create a File manager API key for the release workspace, store it as a CI
> secret, and rotate it independently of my user sessions.
## Future Work
- Evaluate the Better Auth Agent Auth plugin as an optional compatibility layer
once its draft protocol and packages are stable enough for production. Map its
short-lived Agent JWTs and capability grants into the same principal,
workspace, and scope-and-policy boundary defined in v2.9.
- Keep Restish as the operation transport. Until Restish supports Agent Auth
natively, use its reviewed `external-tool` authentication hook with the
official Agent Auth client/signing helper; do not build a standalone ZPan CLI.
- Before enabling Agent Auth on Cloudflare Workers, provide distributed JTI
replay storage and validate revocation across Worker isolates.
- Dynamic client registration for trusted third-party Agent platforms
- Workload identity federation for CI providers to remove stored long-lived
secrets
- HTTP Message Signatures / Web Bot Auth as an additional Agent-operator
authentication signal, never as file authorization by itself
- First-party remote MCP server if Restish's local MCP transport proves
insufficient
- Standard Agent device authorization with the same workspace grant and scopes
- administrator approval or rejection of newly registered applications;
- workload identity for unattended CI;
- standard Agent device authorization;
- richer per-client policy and registration lifecycle management.
+21 -21
View File
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'
import { signUpAndGoToFiles } from './helpers'
const oauthQuery =
'client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback&response_type=code&scope=openid%20offline_access%20objects%3Aread%20shares%3Acreate%20quota%3Aread'
'client_id=dynamic-client&redirect_uri=https%3A%2F%2Fbroker.example.com%2Fcallback&response_type=code&scope=openid%20offline_access%20objects%3Aread%20shares%3Acreate%20quota%3Aread'
test.describe('Agent Access OAuth UI', () => {
test('renders consent details and submits full approval @desktop', async ({ page }) => {
@@ -12,13 +12,13 @@ test.describe('Agent Access OAuth UI', () => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
clientId: 'dynamic-client',
clientName: 'FlareAuth',
instanceOrigin: 'http://localhost:5185',
workspace: { id: 'org-e2e', name: 'Personal' },
scopes: ['objects:read', 'shares:create', 'quota:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
redirectUri: 'https://broker.example.com/callback',
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
}),
})
@@ -30,25 +30,25 @@ test.describe('Agent Access OAuth UI', () => {
expect(body).toEqual({ accept: true, oauthQuery })
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ url: 'http://127.0.0.1:8484/callback?code=e2e-code' }),
body: JSON.stringify({ url: 'https://broker.example.com/callback?code=e2e-code' }),
})
})
await page.route('http://127.0.0.1:8484/callback?code=e2e-code', async (route) => {
await route.fulfill({ contentType: 'text/html', body: '<main>Returned to Restish</main>' })
await page.route('https://broker.example.com/callback?code=e2e-code', async (route) => {
await route.fulfill({ contentType: 'text/html', body: '<main>Returned to FlareAuth</main>' })
})
await page.goto(`/settings/agent-access?${oauthQuery}`)
await expect(page.getByRole('heading', { name: 'Authorize ZPan Agent' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Authorize Application' })).toBeVisible()
await expect(page.getByText('http://localhost:5185')).toBeVisible()
await expect(page.getByText('http://127.0.0.1:8484/callback')).toBeVisible()
await expect(page.getByText('https://broker.example.com/callback')).toBeVisible()
await expect(page.getByText('Files: read objects')).toBeVisible()
await expect(page.getByText('Shares: create shares')).toBeVisible()
await expect(page.getByText('Quota: read workspace quota')).toBeVisible()
await page.getByRole('button', { name: 'Approve Access' }).click()
await expect(page).toHaveURL(/127\.0\.0\.1:8484\/callback\?code=e2e-code/, { timeout: 10000 })
await expect(page.getByText('Returned to Restish')).toBeVisible()
await expect(page).toHaveURL(/broker\.example\.com\/callback\?code=e2e-code/, { timeout: 10000 })
await expect(page.getByText('Returned to FlareAuth')).toBeVisible()
})
test('lists and revokes delegated grants in settings @desktop', async ({ page }) => {
@@ -65,8 +65,8 @@ test.describe('Agent Access OAuth UI', () => {
: [
{
id: 'grant-e2e',
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
clientId: 'dynamic-client',
clientName: 'FlareAuth',
userId: 'user-e2e',
orgId: 'org-e2e',
workspaceName: 'Personal',
@@ -88,7 +88,7 @@ test.describe('Agent Access OAuth UI', () => {
await page.goto('/settings/agent-access')
await expect(page.getByText('Delegated OAuth Grants')).toBeVisible()
await expect(page.getByRole('cell', { name: 'ZPan Agent' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'FlareAuth' })).toBeVisible()
await expect(page.getByText('Shares: create shares')).toBeVisible()
const revokeButtons = page.getByRole('button', { name: 'Revoke' })
@@ -105,13 +105,13 @@ test.describe('Agent Access OAuth UI', () => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
clientId: 'dynamic-client',
clientName: 'FlareAuth',
instanceOrigin: 'http://localhost:5185',
workspace: { id: 'org-e2e', name: 'Personal' },
scopes: ['objects:read', 'shares:create', 'quota:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
redirectUri: 'https://broker.example.com/callback',
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
}),
})
@@ -124,8 +124,8 @@ test.describe('Agent Access OAuth UI', () => {
items: [
{
id: 'grant-mobile',
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
clientId: 'dynamic-client',
clientName: 'FlareAuth',
userId: 'user-e2e',
orgId: 'org-e2e',
workspaceName: 'Personal',
@@ -140,7 +140,7 @@ test.describe('Agent Access OAuth UI', () => {
})
await page.goto(`/settings/agent-access?${oauthQuery}`)
await expect(page.getByRole('heading', { name: 'Authorize ZPan Agent' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Authorize Application' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Approve Access' })).toBeVisible()
await expect(page.getByText('Files: read objects')).toBeVisible()
await expect(page.getByText('Shares: create shares')).toBeVisible()
@@ -150,7 +150,7 @@ test.describe('Agent Access OAuth UI', () => {
await page.goto('/settings/agent-access')
await expect(page.getByText('Delegated OAuth Grants')).toBeVisible()
await expect(page.getByRole('cell', { name: 'ZPan Agent' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'FlareAuth' })).toBeVisible()
const grantsTableContainer = page.locator('[data-slot="table-container"]').last()
await expect(grantsTableContainer).toBeVisible()
await expect
@@ -0,0 +1,66 @@
CREATE TABLE `jwks` (
`id` text PRIMARY KEY NOT NULL,
`public_key` text NOT NULL,
`private_key` text NOT NULL,
`alg` text,
`crv` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`expires_at` integer
);
--> statement-breakpoint
CREATE TABLE `oauthClientAssertion` (
`id` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `oauthClientResource` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`resource_id` text NOT NULL,
`metadata` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauthClient`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`resource_id`) REFERENCES `oauthResource`(`identifier`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `oauthClientResource_client_id_idx` ON `oauthClientResource` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthClientResource_resource_id_idx` ON `oauthClientResource` (`resource_id`);--> statement-breakpoint
CREATE TABLE `oauthResource` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`name` text NOT NULL,
`access_token_ttl` integer,
`refresh_token_ttl` integer,
`signing_algorithm` text,
`signing_key_id` text,
`allowed_scopes` text,
`custom_claims` text,
`dpop_bound_access_tokens_required` integer DEFAULT false,
`disabled` integer DEFAULT false,
`policy_version` integer DEFAULT 1,
`metadata` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauthResource_identifier_unique` ON `oauthResource` (`identifier`);--> statement-breakpoint
CREATE INDEX `oauthResource_identifier_idx` ON `oauthResource` (`identifier`);--> statement-breakpoint
ALTER TABLE `oauthAccessToken` ADD `authorization_code_id` text;--> statement-breakpoint
ALTER TABLE `oauthAccessToken` ADD `resources` text;--> statement-breakpoint
ALTER TABLE `oauthAccessToken` ADD `requested_user_info_claims` text;--> statement-breakpoint
ALTER TABLE `oauthAccessToken` ADD `revoked` integer;--> statement-breakpoint
ALTER TABLE `oauthAccessToken` ADD `confirmation` text;--> statement-breakpoint
ALTER TABLE `oauthClient` ADD `backchannel_logout_uri` text;--> statement-breakpoint
ALTER TABLE `oauthClient` ADD `backchannel_logout_session_required` integer;--> statement-breakpoint
ALTER TABLE `oauthClient` ADD `jwks` text;--> statement-breakpoint
ALTER TABLE `oauthClient` ADD `jwks_uri` text;--> statement-breakpoint
ALTER TABLE `oauthClient` ADD `dpop_bound_access_tokens` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `oauthConsent` ADD `resources` text;--> statement-breakpoint
ALTER TABLE `oauthConsent` ADD `requested_user_info_claims` text;--> statement-breakpoint
ALTER TABLE `oauthRefreshToken` ADD `authorization_code_id` text;--> statement-breakpoint
ALTER TABLE `oauthRefreshToken` ADD `resources` text;--> statement-breakpoint
ALTER TABLE `oauthRefreshToken` ADD `requested_user_info_claims` text;--> statement-breakpoint
ALTER TABLE `oauthRefreshToken` ADD `rotated_at` integer;--> statement-breakpoint
ALTER TABLE `oauthRefreshToken` ADD `rotation_replay_response` text;--> statement-breakpoint
ALTER TABLE `oauthRefreshToken` ADD `rotation_replay_expires_at` integer;--> statement-breakpoint
ALTER TABLE `oauthRefreshToken` ADD `confirmation` text;
@@ -0,0 +1,2 @@
ALTER TABLE `account` ADD `issuer` text DEFAULT '' NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX `account_issuer_providerAccountId_unique` ON `account` (`issuer`,`account_id`);
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE `oauthJwtRevocation` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `oauthJwtRevocation_expires_at_idx` ON `oauthJwtRevocation` (`expires_at`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -575,6 +575,27 @@
"when": 1785351402721,
"tag": "0082_agent_oauth_consent_last_used_at",
"breakpoints": true
},
{
"idx": 83,
"version": "6",
"when": 1785388050472,
"tag": "0083_external-resource-oauth",
"breakpoints": true
},
{
"idx": 84,
"version": "6",
"when": 1785388374965,
"tag": "0084_better-auth-account-issuer",
"breakpoints": true
},
{
"idx": 85,
"version": "6",
"when": 1785388778835,
"tag": "0085_oauth-jwt-revocation",
"breakpoints": true
}
]
}
+11 -11
View File
@@ -39,7 +39,6 @@
"lint:arch": "depcruise server/ shared/ --config .dependency-cruiser.cjs",
"lint:http": "tsx scripts/lint-http-boundary.ts",
"lint:spec": "node scripts/lint-spec.mjs",
"lint:zpan-skill": "node scripts/lint-zpan-skill.mjs",
"prepare": "husky",
"format": "biome format --write .",
"e2e": "playwright test",
@@ -57,8 +56,8 @@
"@aws-sdk/client-s3": "^3.1022.0",
"@aws-sdk/s3-request-presigner": "^3.1022.0",
"@azure/functions": "^4.12.0",
"@better-auth/api-key": "^1.6.14",
"@better-auth/oauth-provider": "1.6.14",
"@better-auth/api-key": "1.7.0-rc.2",
"@better-auth/oauth-provider": "1.7.0-rc.2",
"@better-captcha/react": "^0.7.0",
"@dnd-kit/core": "^6.3.1",
"@hono/node-server": "^2.0.10",
@@ -73,7 +72,7 @@
"@uiw/react-markdown-preview": "5.2.0",
"@uiw/react-md-editor": "^4.1.0",
"@vidstack/react": "^1.12.13",
"better-auth": "^1.6.14",
"better-auth": "1.7.0-rc.2",
"better-sqlite3": "^12.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -84,6 +83,7 @@
"hono": "^4.12.27",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"jose": "6.2.3",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.11",
"next-themes": "^0.4.6",
@@ -168,13 +168,13 @@
},
"pnpm": {
"overrides": {
"@better-auth/core": "1.6.14",
"@better-auth/drizzle-adapter": "1.6.14",
"@better-auth/kysely-adapter": "1.6.14",
"@better-auth/memory-adapter": "1.6.14",
"@better-auth/mongo-adapter": "1.6.14",
"@better-auth/prisma-adapter": "1.6.14",
"@better-auth/telemetry": "1.6.14",
"@better-auth/core": "1.7.0-rc.2",
"@better-auth/drizzle-adapter": "1.7.0-rc.2",
"@better-auth/kysely-adapter": "1.7.0-rc.2",
"@better-auth/memory-adapter": "1.7.0-rc.2",
"@better-auth/mongo-adapter": "1.7.0-rc.2",
"@better-auth/prisma-adapter": "1.7.0-rc.2",
"@better-auth/telemetry": "1.7.0-rc.2",
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
+119 -116
View File
@@ -5,13 +5,13 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@better-auth/core': 1.6.14
'@better-auth/drizzle-adapter': 1.6.14
'@better-auth/kysely-adapter': 1.6.14
'@better-auth/memory-adapter': 1.6.14
'@better-auth/mongo-adapter': 1.6.14
'@better-auth/prisma-adapter': 1.6.14
'@better-auth/telemetry': 1.6.14
'@better-auth/core': 1.7.0-rc.2
'@better-auth/drizzle-adapter': 1.7.0-rc.2
'@better-auth/kysely-adapter': 1.7.0-rc.2
'@better-auth/memory-adapter': 1.7.0-rc.2
'@better-auth/mongo-adapter': 1.7.0-rc.2
'@better-auth/prisma-adapter': 1.7.0-rc.2
'@better-auth/telemetry': 1.7.0-rc.2
'@vitest/expect': 4.1.4
'@vitest/mocker': 4.1.4
'@vitest/pretty-format': 4.1.4
@@ -36,11 +36,11 @@ importers:
specifier: ^4.12.0
version: 4.12.0
'@better-auth/api-key':
specifier: ^1.6.14
version: 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.14(@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.5(zod@4.4.3))
specifier: 1.7.0-rc.2
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.6.14
version: 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-auth@1.6.14(@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.5(zod@4.4.3))
specifier: 1.7.0-rc.2
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-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)
@@ -84,8 +84,8 @@ importers:
specifier: ^1.12.13
version: 1.12.13(@types/react@19.2.14)(react@19.2.5)
better-auth:
specifier: ^1.6.14
version: 1.6.14(@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)
specifier: 1.7.0-rc.2
version: 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-sqlite3:
specifier: ^12.10.0
version: 12.10.0
@@ -116,6 +116,9 @@ importers:
i18next-browser-languagedetector:
specifier: ^8.2.1
version: 8.2.1
jose:
specifier: 6.2.3
version: 6.2.3
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.5)
@@ -568,22 +571,22 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@better-auth/api-key@1.6.14':
resolution: {integrity: sha512-iMLRcjpGyegI5yy375ZIw83HZGSZe6TwjtCKWdFTYy1PQ0bUcD0H61uKcvO82Co4jJmjakI3POR6lDy5W1OOew==}
'@better-auth/api-key@1.7.0-rc.2':
resolution: {integrity: sha512-7fyxJhOKKWQ+TtLTvCjxfZU+blE+jvwe4rzunz1RfOafuWEukE2ofRQ8uOMG4ZUYG6SO+tVKxsFDnbJZj2Da3g==}
peerDependencies:
'@better-auth/core': 1.6.14
'@better-auth/utils': 0.4.1
better-auth: ^1.6.14
better-call: 1.3.5
'@better-auth/core': 1.7.0-rc.2
'@better-auth/utils': 0.4.2
better-auth: ^1.7.0-rc.2
better-call: 1.3.7
'@better-auth/core@1.6.14':
resolution: {integrity: sha512-12cA7tnR4Wyb3nLpPmeq/Id7QNB+4OhjbzuX7sIhqglgXGjyT5iiNpe2lx/8FF532sHC450Yx1850salCYbkzw==}
'@better-auth/core@1.7.0-rc.2':
resolution: {integrity: sha512-NreNGg68j4qUVVYTcC1DtvRTwSJdCavH5igrMyTO5ghZxnzL4G539uRIzOZmJ64MLzOyOwzWH+JHqpVaj0ZRxw==}
peerDependencies:
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@cloudflare/workers-types': '>=4'
'@opentelemetry/api': ^1.9.0
better-call: 1.3.5
better-call: 1.3.7
jose: ^6.1.0
kysely: ^0.28.5 || ^0.29.0
nanostores: ^1.0.1
@@ -593,56 +596,56 @@ packages:
'@opentelemetry/api':
optional: true
'@better-auth/drizzle-adapter@1.6.14':
resolution: {integrity: sha512-lYs1jDudriKYMXNcLFLAvEvOEKbeKBFdDciG4H8qZhV+3+yghGC3f/H5qtgTDc8mGBPV+2tEvVgYqReurOSmNw==}
'@better-auth/drizzle-adapter@1.7.0-rc.2':
resolution: {integrity: sha512-o6HCC8PCyvg1/BQaNWvJM7kO8svXWvuM++APj7ah+iEfFWcp0yklNQWLijDLu+PAaoKHwNMgDpmclDruirHdPA==}
peerDependencies:
'@better-auth/core': 1.6.14
'@better-auth/utils': 0.4.1
drizzle-orm: ^0.45.2
'@better-auth/core': 1.7.0-rc.2
'@better-auth/utils': 0.4.2
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
peerDependenciesMeta:
drizzle-orm:
optional: true
'@better-auth/kysely-adapter@1.6.14':
resolution: {integrity: sha512-A2+381gYADuZpgd98XQ39bnxLzbT03wnnDmSQIXp7XcE3hF093mGMk6rxlAhENVHH7JL2B0Tv2la2o6n+6ppyQ==}
'@better-auth/kysely-adapter@1.7.0-rc.2':
resolution: {integrity: sha512-g65JeOOseffsqHJXOM0/+SdPvojXzFPejVEFKuUatkdfXcw/l0zEiEkH38Ag5SWFaqfvog/wRPY/MK8PC/ODvg==}
peerDependencies:
'@better-auth/core': 1.6.14
'@better-auth/utils': 0.4.1
'@better-auth/core': 1.7.0-rc.2
'@better-auth/utils': 0.4.2
kysely: ^0.28.17 || ^0.29.0
peerDependenciesMeta:
kysely:
optional: true
'@better-auth/memory-adapter@1.6.14':
resolution: {integrity: sha512-frtBTozi8qsBlypxp33dkiIZT2IOMvix3oh2qTTcBkK11ISsRSTUUadl7DbwXri2AEoooShsH6PSAput920J3Q==}
'@better-auth/memory-adapter@1.7.0-rc.2':
resolution: {integrity: sha512-ACP69pbSDnIYYcx/KEtRXpFmte6q0Adh3028pRP5aDydkmbcCc7cFiwnRMQuI/MY7aBfW0wefEbcEwOse61Hcg==}
peerDependencies:
'@better-auth/core': 1.6.14
'@better-auth/utils': 0.4.1
'@better-auth/core': 1.7.0-rc.2
'@better-auth/utils': 0.4.2
'@better-auth/mongo-adapter@1.6.14':
resolution: {integrity: sha512-meaZx712k9c0Cl6urwYZRNa3mAy3/leaYiSNt+hVaCOEPlgTDxzmYMNACvTTYXgh4eCpDVf5G7ZMEYBtejKQdw==}
'@better-auth/mongo-adapter@1.7.0-rc.2':
resolution: {integrity: sha512-/QeC23KheruIamhu4XIqtPLcvupoDXmSpPB9QvmVqQcb5oVjAILJo6kIbZtq/JYbaHjrIO7mpdH6404hyv7weg==}
peerDependencies:
'@better-auth/core': 1.6.14
'@better-auth/utils': 0.4.1
'@better-auth/core': 1.7.0-rc.2
'@better-auth/utils': 0.4.2
mongodb: ^6.0.0 || ^7.0.0
peerDependenciesMeta:
mongodb:
optional: true
'@better-auth/oauth-provider@1.6.14':
resolution: {integrity: sha512-JL5UNKayERwRbYyZL7DsjOMtMjPWiOVnzUwztIuDNuYK5JZC2Pfm/16MdkEtic8+8YCv9qGK7dph/TTU5fdlKA==}
'@better-auth/oauth-provider@1.7.0-rc.2':
resolution: {integrity: sha512-fc3jCYwS/PaQyErOPqIUplqK456zhrmNWGnJPhDEF68merXBQN1OodUTzicZ3skFDpAv6MY3m5vk4D1Gz3R/oA==}
peerDependencies:
'@better-auth/core': 1.6.14
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
better-auth: ^1.6.14
better-call: 1.3.5
'@better-auth/core': 1.7.0-rc.2
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
better-auth: ^1.7.0-rc.2
better-call: 1.3.7
'@better-auth/prisma-adapter@1.6.14':
resolution: {integrity: sha512-9b9wSqhCthMmOYo0QdX+N/cOv+fNck/JE5CZQuuWwEJl5QeoYhCZesXjts5VfLAPMIf6vKw3QNBrn0SVMXXi2Q==}
'@better-auth/prisma-adapter@1.7.0-rc.2':
resolution: {integrity: sha512-OFRJbg44ha2zD5lpXIKfoGBEwPe58YBhwIgKlfRuHpsZSpknaXOTvFIH2da8q8SK5L5mjYQ9vSsnX0oOf8gNDA==}
peerDependencies:
'@better-auth/core': 1.6.14
'@better-auth/utils': 0.4.1
'@better-auth/core': 1.7.0-rc.2
'@better-auth/utils': 0.4.2
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
peerDependenciesMeta:
@@ -651,15 +654,15 @@ packages:
prisma:
optional: true
'@better-auth/telemetry@1.6.14':
resolution: {integrity: sha512-ALi3cEx5eyrFY+TeAdhc1uq8FqJyGvzgvIo7GQZOqGqLZxHY9nte44WN++jBFGJJbsW3e4cgLj8dQK291s6wWQ==}
'@better-auth/telemetry@1.7.0-rc.2':
resolution: {integrity: sha512-sSZ+/FkG/axBjXVeF01LT+NQjT23TLwRwpdkcI8FJBWINNUCYhuxgdi05dv70MeX8iocfMhKxQ4EPgvt/eW/kQ==}
peerDependencies:
'@better-auth/core': 1.6.14
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@better-auth/core': 1.7.0-rc.2
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@better-auth/utils@0.4.1':
resolution: {integrity: sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA==}
'@better-auth/utils@0.4.2':
resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==}
'@better-captcha/core@0.7.0':
resolution: {integrity: sha512-fOTHeBbhf7WzstFp67eeaKyvrnH+nvPfsy1fyz+cj8scn16pgOxuwHONdnr23dXEpfa9DngKJEq/4TNGvX8Mkw==}
@@ -670,8 +673,8 @@ packages:
react: ^18 || ^19
typescript: ^5.0.0
'@better-fetch/fetch@1.1.21':
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
'@better-fetch/fetch@1.3.1':
resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==}
'@biomejs/biome@2.4.11':
resolution: {integrity: sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA==}
@@ -3535,8 +3538,8 @@ packages:
bcp-47-match@2.0.3:
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
better-auth@1.6.14:
resolution: {integrity: sha512-c0/DvTQGDpgfj1knekCpQrg6PSWGDtfAtP7Ou6FkAhoE3RNnnIxLB5qKj6tRg53a1xsq93G6T68cNxrUZ7ZVmw==}
better-auth@1.7.0-rc.2:
resolution: {integrity: sha512-5KZrqbAsoQA8q1edmufaoF/CBbMjGb/BoPqyMTzXFyDeXNhk8pXO2xJkiDDeZcSGtyhUKXiDnD7hxh4sJVgYZw==}
peerDependencies:
'@lynx-js/react': '*'
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
@@ -3544,7 +3547,7 @@ packages:
'@tanstack/react-start': ^1.0.0
'@tanstack/solid-start': ^1.0.0
better-sqlite3: ^12.0.0
drizzle-kit: '>=0.31.4'
drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
drizzle-orm: ^0.45.2
mongodb: ^6.0.0 || ^7.0.0
mysql2: ^3.0.0
@@ -3597,8 +3600,8 @@ packages:
vue:
optional: true
better-call@1.3.5:
resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==}
better-call@1.3.7:
resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==}
peerDependencies:
zod: ^4.0.0
peerDependenciesMeta:
@@ -6417,21 +6420,21 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@better-auth/api-key@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.14(@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.5(zod@4.4.3))':
'@better-auth/api-key@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))':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
better-auth: 1.6.14(@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.5(zod@4.4.3)
'@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)
zod: 4.4.3
'@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)':
'@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)':
dependencies:
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@opentelemetry/semantic-conventions': 1.41.1
'@standard-schema/spec': 1.1.0
better-call: 1.3.5(zod@4.4.3)
better-call: 1.3.7(zod@4.4.3)
jose: 6.2.3
kysely: 0.28.17
nanostores: 1.3.0
@@ -6440,52 +6443,52 @@ snapshots:
'@cloudflare/workers-types': 4.20260606.1
'@opentelemetry/api': 1.9.1
'@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(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))':
'@better-auth/drizzle-adapter@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)(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))':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@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
optionalDependencies:
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)
'@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17)':
'@better-auth/kysely-adapter@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)(kysely@0.28.17)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@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
optionalDependencies:
kysely: 0.28.17
'@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
'@better-auth/memory-adapter@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)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@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/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
'@better-auth/mongo-adapter@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)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@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.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-auth@1.6.14(@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.5(zod@4.4.3))':
'@better-auth/oauth-provider@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-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.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.6.14(@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.5(zod@4.4.3)
'@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)
jose: 6.2.3
zod: 4.4.3
'@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
'@better-auth/prisma-adapter@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)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@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/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)':
'@better-auth/telemetry@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-fetch/fetch@1.3.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@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/utils@0.4.1':
'@better-auth/utils@0.4.2':
dependencies:
'@noble/hashes': 2.2.0
@@ -6497,7 +6500,7 @@ snapshots:
react: 19.2.5
typescript: 5.9.3
'@better-fetch/fetch@1.1.21': {}
'@better-fetch/fetch@1.3.1': {}
'@biomejs/biome@2.4.11':
optionalDependencies:
@@ -8965,20 +8968,20 @@ snapshots:
bcp-47-match@2.0.3: {}
better-auth@1.6.14(@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-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):
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(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))
'@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.28.17)
'@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@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/drizzle-adapter': 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)(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))
'@better-auth/kysely-adapter': 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)(kysely@0.28.17)
'@better-auth/memory-adapter': 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/mongo-adapter': 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/prisma-adapter': 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/telemetry': 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-fetch/fetch@1.3.1)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@noble/ciphers': 2.2.0
'@noble/hashes': 2.2.0
better-call: 1.3.5(zod@4.4.3)
better-call: 1.3.7(zod@4.4.3)
defu: 6.1.7
jose: 6.2.3
kysely: 0.28.17
@@ -8995,10 +8998,10 @@ snapshots:
- '@cloudflare/workers-types'
- '@opentelemetry/api'
better-call@1.3.5(zod@4.4.3):
better-call@1.3.7(zod@4.4.3):
dependencies:
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
rou3: 0.7.12
set-cookie-parser: 3.1.0
optionalDependencies:
+5 -5
View File
@@ -67,14 +67,14 @@ if (githubClientId && githubClientSecret) {
}
// create storage
const storageRes = await app.request('/api/admin/storages', {
const storageRes = await app.request('/api/site/storages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body: JSON.stringify(storageConfig),
})
if (!storageRes.ok) throw new Error(`create storage failed: ${storageRes.status} ${await storageRes.text()}`)
const storage = (await storageRes.json()) as { id: string; title: string }
console.log(`created storage: ${storage.title} (${storage.id})`)
const storage = (await storageRes.json()) as { id: string; bucket: string }
console.log(`created storage: ${storage.bucket} (${storage.id})`)
console.log('\ndone!')
@@ -92,7 +92,7 @@ function resetNode(): Platform {
migrate(db, { migrationsFolder: './migrations' })
console.log('database migrated')
return { db, getEnv: (key) => process.env[key] }
return { db, getEnv: (key) => process.env[key], getBinding: () => undefined }
}
function resetD1(): Platform {
@@ -111,7 +111,7 @@ function resetD1(): Platform {
const sqlite = new Database(dbFile)
const db = drizzle(sqlite, { schema: { ...schema, ...authSchema } })
return { db, getEnv: (key) => process.env[key] }
return { db, getEnv: (key) => process.env[key], getBinding: () => undefined }
}
function findD1SqliteFile(): string {
-203
View File
@@ -1,203 +0,0 @@
#!/usr/bin/env node
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
const ROOT = process.cwd()
const SKILL_DIR = join(ROOT, 'skills', 'zpan')
function walkMarkdown(dir) {
const files = []
for (const name of readdirSync(dir)) {
const full = join(dir, name)
const stat = statSync(full)
if (stat.isDirectory()) files.push(...walkMarkdown(full))
else if (name.endsWith('.md')) files.push(full)
}
return files.sort()
}
const files = walkMarkdown(SKILL_DIR)
const documents = files.map((file) => ({
file,
rel: relative(ROOT, file),
text: readFileSync(file, 'utf8'),
}))
const corpus = documents.map((doc) => doc.text).join('\n\n')
const normalizedCorpus = corpus.toLowerCase()
const failures = []
function requireMatch(label, pattern) {
if (!pattern.test(corpus)) failures.push(`missing required contract: ${label}`)
}
function requireText(label, text) {
if (!normalizedCorpus.includes(text.toLowerCase())) {
failures.push(`missing required contract: ${label}`)
}
}
function forbidMatch(label, pattern) {
for (const doc of documents) {
for (const match of doc.text.matchAll(pattern)) {
const line = doc.text.slice(0, match.index).split('\n').length
failures.push(`forbidden contract text: ${label} (${doc.rel}:${line})`)
}
}
}
function forbidUnsafeLine(label, pattern) {
const safePrefix = /\b(do not|don't|never|must not|not|no)\b/i
for (const doc of documents) {
const lines = doc.text.split('\n')
lines.forEach((lineText, index) => {
if (pattern.test(lineText) && !safePrefix.test(lineText)) {
failures.push(`unsafe contract guidance: ${label} (${doc.rel}:${index + 1})`)
}
})
}
}
function commandLines() {
return corpus
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('restish ') || /^RSH_PROFILE=\S+\s+restish\b/.test(line))
}
function requireCommandLine(label, pattern) {
if (!commandLines().some((line) => pattern.test(line))) {
failures.push(`missing executable command example: ${label}`)
}
}
function forbidCommandLine(label, pattern) {
for (const doc of documents) {
const lines = doc.text.split('\n')
lines.forEach((lineText, index) => {
const line = lineText.trim()
if (/^(?:RSH_PROFILE=\S+\s+)?restish /.test(line) && pattern.test(line)) {
failures.push(`forbidden executable command: ${label} (${doc.rel}:${index + 1})`)
}
})
}
}
function validateSkillFrontmatter() {
const skill = documents.find((doc) => doc.rel === 'skills/zpan/SKILL.md')
if (!skill) {
failures.push('missing skills/zpan/SKILL.md')
return
}
const match = skill.text.match(/^---\n([\s\S]*?)\n---\n/)
if (!match) {
failures.push('missing Skill YAML frontmatter')
return
}
const keys = [...match[1].matchAll(/^([A-Za-z0-9_-]+):/gm)].map((entry) => entry[1])
const extras = keys.filter((key) => key !== 'name' && key !== 'description')
if (extras.length > 0) {
failures.push(`unsupported Skill frontmatter key(s): ${extras.join(', ')}`)
}
}
validateSkillFrontmatter()
requireMatch('Restish v2.3 or later', /Restish v2\.3(?:\+| or later)/i)
requireText('connect exactly /api/openapi.json', '/api/openapi.json')
requireText('plugin install command', 'restish plugin install saltbo/zpan zpan')
requireText('upload command surface', 'restish zpan-upload')
for (const command of [
'list-objects',
'get-object',
'create-object',
'update-object',
'copy-object',
'transfer-object',
'delete-object',
'list-shares',
'create-share',
'revoke-share',
'get-user-quota',
'get-storage-usage',
'list-download-tasks',
'get-download-task',
'list-download-task-events',
]) {
requireCommandLine(`restish zpan ${command}`, new RegExp(`\\brestish\\s+(?:--rsh-profile\\s+\\S+\\s+)?zpan\\s+${command}\\b`))
}
for (const operationId of ['createObject', 'presignObjectUploadParts', 'completeObjectUpload', 'abortObjectUpload']) {
requireText(`upload plugin validates ${operationId}`, operationId)
}
requireCommandLine('list pagination uses --page-size', /\bzpan\s+list-objects\b.*\s--page-size\s+\d+/)
requireCommandLine('share pagination uses --page-size', /\bzpan\s+list-shares\b.*\s--page-size\s+\d+/)
requireCommandLine('task pagination uses --page-size', /\bzpan\s+list-download-tasks\b.*\s--page-size\s+\d+/)
requireCommandLine('create-object uses positional body input', /\bzpan\s+create-object\s+'[^']*\bname:/)
requireCommandLine('update-object uses positional body input', /\bzpan\s+update-object\s+\S+\s+'[^']*\bname:/)
requireCommandLine('copy-object uses positional body input', /\bzpan\s+copy-object\s+\S+\s+'[^']*\bparent:/)
requireCommandLine('transfer-object uses positional body input', /\bzpan\s+transfer-object\s+\S+\s+'[^']*\btargetOrgId:/)
requireCommandLine('create-share uses positional body input', /\bzpan\s+create-share\s+'[^']*\bmatterId:/)
requireCommandLine('revoke-share uses positional body input', /\bzpan\s+revoke-share\s+\S+\s+'[^']*\bstatus:\s*revoked/)
requireCommandLine('upload passes Restish and plugin profiles', /\bRSH_PROFILE=(\S+)\s+restish\s+zpan-upload\b.*\s--api\s+zpan\b.*\s--profile\s+\1\b/)
requireText('reader profile', '`reader`')
requireText('file-manager profile', '`file-manager`')
requireText('publisher profile', '`publisher`')
requireText('ci profile', '`ci`')
requireMatch('least-privilege profile selection', /(least-privilege|narrowest) profile/i)
requireText('objects read scope', 'objects:read')
requireText('objects write scopes', 'objects:create')
requireText('share publishing scopes', 'shares:create')
requireText('environment-backed Agent API key', 'Environment-backed')
requireMatch('OAuth authorization code with PKCE', /OAuth authorization code \+ PKCE|authorization code\s*\+\s*PKCE/i)
requireMatch('CI Agent API key guidance', /CI[\s\S]{0,240}Agent API key|Agent API key[\s\S]{0,240}CI/i)
requireMatch('confirm target workspace', /confirm[\s\S]{0,120}workspace/i)
requireMatch('confirm conflict policy', /confirm[\s\S]{0,160}(conflict|overwrite|replace)/i)
requireMatch('confirm destructive delete', /confirm[\s\S]{0,160}(destructive|soft delete|delet)/i)
requireMatch('confirm permanent purge', /confirm[\s\S]{0,160}(purge|permanent)/i)
requireMatch('confirm public sharing', /confirm[\s\S]{0,160}public share/i)
requireMatch(
'confirm plugin executable trust',
/(?:confirm|ask)[\s\S]{0,200}(trusted local executable|executable Restish plugin|plugin trust)/i,
)
forbidMatch('agent OpenAPI document', /\/api\/openapi\.agent\.json/gi)
forbidMatch('standalone zpan file CLI', /standalone\s+`?zpan`?\s+file CLI/gi)
const openApiDocs = [...corpus.matchAll(/\/api\/openapi(?:\.[a-z0-9-]+)?\.json/gi)].map((match) => match[0])
for (const doc of openApiDocs) {
if (doc !== '/api/openapi.json') {
failures.push(`OpenAPI document must be exactly /api/openapi.json, found ${doc}`)
}
}
forbidUnsafeLine('bearer-token paste flow', /\b(paste|copy\/paste|copy paste)\b.*\bbearer token\b/i)
forbidUnsafeLine('Agent device login as v2.9 flow', /\b(device authorization|device login|device flow)\b.*\bv2\.9\b/i)
forbidUnsafeLine('Skill-handled multipart orchestration', /\b(Skill|agent)\b.*\b(orchestrate|handle|implement)\b.*\bmultipart\b/i)
forbidUnsafeLine('Skill-handled ETag retry loop', /\b(Skill|agent)\b.*\b(ETag|ETags)\b.*\b(retry|retries|loop|loops)\b/i)
forbidUnsafeLine('presigned URL exposure', /\b(expose|return|print|show)\b.*\bpresigned URLs?\b/i)
forbidMatch('silent plugin install approval', /restish\s+plugin\s+install\s+saltbo\/zpan\s+zpan[^\n]*--yes/gi)
forbidMatch('old Restish list limit flag', /\brestish\s+(?:--rsh-profile\s+\S+\s+)?zpan\s+(?:list-objects|list-shares|list-download-tasks)\b[^\n]*\s--limit\b/gi)
forbidMatch('camelCase Restish command example', /\brestish\s+(?:--rsh-profile\s+\S+\s+)?zpan\s+(?:listObjects|getObject|createObject|updateObject|copyObject|transferObject|deleteObject|purgeTrashObject|listShares|createShare|revokeShare|getUserQuota|getStorageUsage|listDownloadTasks|getDownloadTask|listDownloadTaskEvents)\b/gi)
forbidMatch('profile template purge command', /\brestish\s+--rsh-profile\s+(?:reader|file-manager|publisher|ci)\s+zpan\s+purge-trash-object\b/gi)
forbidMatch('invented operator profile', /\brestish\s+--rsh-profile\s+operator\b/gi)
forbidCommandLine('upload without plugin profile', /\b(?:RSH_PROFILE=\S+\s+)?restish\s+(?:--rsh-profile\s+\S+\s+)?zpan-upload\b(?!.*\s--profile\s+\S+)/i)
forbidMatch('upload with ineffective host profile flag', /\brestish\s+--rsh-profile\s+\S+\s+zpan-upload\b/gi)
forbidCommandLine('upload without delegated profile environment', /^restish\s+zpan-upload\b/i)
forbidMatch(
'MCP upload control-plane allowlist',
/restish\s+mcp\s+serve[\s\S]*?--operations[^\n]*(createObject|create-object|presignObjectUploadParts|presign-object-upload-parts|completeObjectUpload|complete-object-upload|abortObjectUpload|abort-object-upload)/gi,
)
if (failures.length > 0) {
console.error(`ZPan Skill static contract failed with ${failures.length} finding(s):`)
for (const failure of failures) console.error(`- ${failure}`)
process.exit(1)
}
console.log(`ZPan Skill static contract passed (${documents.length} markdown files checked)`)
+79 -285
View File
@@ -1,125 +1,41 @@
import { createHash } from 'node:crypto'
import { AGENT_OAUTH_CLIENT_ID } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { eq, isNull } from 'drizzle-orm'
import { isNull } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../../db/auth-schema'
import { createTestApp } from '../../test/setup'
import { createAgentOAuthGateway } from './agent-oauth'
const CLIENT_ID = 'dynamic-client'
describe('Agent OAuth gateway', () => {
it('provisions the system public native client', async () => {
it('finds and lists dynamically registered applications', async () => {
const { db } = await createTestApp()
const [client] = await db
.select()
.from(authSchema.oauthClient)
.where(eq(authSchema.oauthClient.clientId, AGENT_OAUTH_CLIENT_ID))
await insertClient(db, CLIENT_ID, 'FlareAuth')
await insertClient(db, 'retired-system-client', 'Retired', 'system')
expect(client).toMatchObject({
clientId: AGENT_OAUTH_CLIENT_ID,
tokenEndpointAuthMethod: 'none',
public: true,
type: 'native',
requirePKCE: true,
await expect(createAgentOAuthGateway().findClient(db, CLIENT_ID)).resolves.toMatchObject({
clientId: CLIENT_ID,
clientName: 'FlareAuth',
disabled: false,
redirectUris: ['https://flareauth.example/callback'],
responseTypes: ['code'],
})
expect(JSON.parse(client.redirectUris)).toEqual([
'http://localhost:8484/callback',
'http://127.0.0.1:8484/callback',
await expect(createAgentOAuthGateway().listRegisteredApplications(db)).resolves.toEqual([
expect.objectContaining({ clientId: CLIENT_ID, name: 'FlareAuth' }),
])
expect(JSON.parse(client.grantTypes ?? '[]')).toEqual(['authorization_code', 'refresh_token'])
await expect(createAgentOAuthGateway().findClient(db, 'retired-system-client')).resolves.toBeNull()
})
it('verifies access tokens only while consent is live and scoped to the workspace', async () => {
const { db } = await createTestApp()
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
})
await db.insert(authSchema.oauthAccessToken).values({
id: 'access-1',
token: hashStoredToken('opaque-token'),
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
})
const token = await createAgentOAuthGateway().verifyAccessToken(db, 'opaque-token')
expect(token).toEqual({
grantId: 'grant-1',
userId,
orgId,
clientId: AGENT_OAUTH_CLIENT_ID,
scopes: [AuthorizationScope.OBJECTS_READ],
})
await db.delete(authSchema.oauthConsent).where(eq(authSchema.oauthConsent.id, 'grant-1'))
await expect(createAgentOAuthGateway().verifyAccessToken(db, 'opaque-token')).resolves.toBeNull()
})
it('requires the managed client, workspace, and granted scopes before minting claims', async () => {
const { db } = await createTestApp()
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
})
await expect(
createAgentOAuthGateway().assertLiveGrant(db, {
userId,
clientId: AGENT_OAUTH_CLIENT_ID,
scopes: [AuthorizationScope.OBJECTS_READ],
}),
).rejects.toThrow('agent_oauth_workspace_required')
await expect(
createAgentOAuthGateway().assertLiveGrant(db, {
userId,
clientId: 'other-client',
orgId,
scopes: [AuthorizationScope.OBJECTS_READ],
}),
).rejects.toThrow('agent_oauth_client_denied')
await expect(
createAgentOAuthGateway().assertLiveGrant(db, {
userId,
clientId: AGENT_OAUTH_CLIENT_ID,
orgId,
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
}),
).rejects.toThrow('agent_oauth_scope_denied')
})
it('lists only workspace-bound grants for the managed client', async () => {
it('lists workspace-bound grants with their registered application names', async () => {
const { db } = await createTestApp()
await insertClient(db, CLIENT_ID, 'FlareAuth')
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.oauthConsent).values([
{
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
clientId: CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
@@ -129,42 +45,20 @@ describe('Agent OAuth gateway', () => {
},
{
id: 'grant-without-workspace',
clientId: AGENT_OAUTH_CLIENT_ID,
clientId: CLIENT_ID,
userId,
referenceId: null,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date('2026-07-29T12:02:00.000Z'),
lastUsedAt: null,
updatedAt: new Date('2026-07-29T12:03:00.000Z'),
},
])
await db.insert(authSchema.oauthAccessToken).values([
{
id: 'access-older',
token: 'hashed-access-older',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date('2026-07-29T12:05:00.000Z'),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
{
id: 'access-newer',
token: 'hashed-access-newer',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date('2026-07-29T12:10:00.000Z'),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
},
])
await expect(createAgentOAuthGateway().listGrants(db, userId)).resolves.toEqual([
{
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
clientId: CLIENT_ID,
clientName: 'FlareAuth',
userId,
orgId,
scopes: [AuthorizationScope.OBJECTS_READ],
@@ -174,138 +68,35 @@ describe('Agent OAuth gateway', () => {
])
})
it('records actual delegated grant use without treating token issuance as use', async () => {
it('revokes the selected dynamic-client grant family only', async () => {
const { db } = await createTestApp()
const gateway = createAgentOAuthGateway()
await insertClient(db, CLIENT_ID, 'FlareAuth')
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date('2026-07-29T12:00:00.000Z'),
updatedAt: new Date('2026-07-29T12:01:00.000Z'),
})
await db.insert(authSchema.oauthAccessToken).values({
id: 'access-1',
token: 'hashed-access',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date('2026-07-29T12:10:00.000Z'),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
})
await expect(gateway.listGrants(db, userId)).resolves.toMatchObject([{ id: 'grant-1', lastUsedAt: null }])
await gateway.recordGrantUse(db, {
grantId: 'grant-1',
userId,
orgId,
now: new Date('2026-07-29T12:30:00.000Z'),
})
await expect(gateway.listGrants(db, userId)).resolves.toMatchObject([
{ id: 'grant-1', lastUsedAt: '2026-07-29T12:30:00.000Z' },
])
})
it('revokes only the managed client grant for the selected workspace', async () => {
const { db } = await createTestApp()
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.organization).values({ id: 'oauth-org-2', name: 'OAuth Org 2', slug: 'oauth-org-2' })
await db
.insert(authSchema.member)
.values({ id: 'oauth-org-2-member', organizationId: 'oauth-org-2', userId, role: 'owner' })
await db.insert(authSchema.oauthClient).values({
id: 'other-client',
clientId: 'other-client',
clientSecret: null,
disabled: false,
skipConsent: false,
enableEndSession: false,
subjectType: 'public',
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
name: 'Other Client',
redirectUris: JSON.stringify(['http://localhost/callback']),
tokenEndpointAuthMethod: 'none',
grantTypes: JSON.stringify(['authorization_code']),
responseTypes: JSON.stringify(['code']),
public: true,
type: 'native',
requirePKCE: true,
})
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
clientId: CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
})
await db.insert(authSchema.oauthConsent).values([
{
id: 'grant-2',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: 'oauth-org-2',
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'other-grant',
clientId: 'other-client',
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
},
])
await db.insert(authSchema.oauthRefreshToken).values({
id: 'refresh-1',
token: 'hashed-refresh',
clientId: AGENT_OAUTH_CLIENT_ID,
clientId: CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
})
await db.insert(authSchema.oauthRefreshToken).values([
{
id: 'refresh-2',
token: 'hashed-refresh-2',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: 'oauth-org-2',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
{
id: 'other-refresh',
token: 'hashed-other-refresh',
clientId: 'other-client',
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
])
await db.insert(authSchema.oauthAccessToken).values({
id: 'access-1',
token: 'hashed-access',
clientId: AGENT_OAUTH_CLIENT_ID,
clientId: CLIENT_ID,
userId,
referenceId: orgId,
refreshId: 'refresh-1',
@@ -313,59 +104,66 @@ describe('Agent OAuth gateway', () => {
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
})
await db.insert(authSchema.oauthAccessToken).values([
{
id: 'access-2',
token: 'hashed-access-2',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: 'oauth-org-2',
refreshId: 'refresh-2',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
{
id: 'other-access',
token: 'hashed-other-access',
clientId: 'other-client',
userId,
referenceId: orgId,
refreshId: 'other-refresh',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
])
const revoked = await createAgentOAuthGateway().revokeGrant(db, {
userId,
grantId: 'grant-1',
now: new Date('2026-07-29T12:00:00.000Z'),
})
expect(revoked).toBe(true)
expect((await db.select().from(authSchema.oauthConsent)).map((row) => row.id).sort()).toEqual([
'grant-2',
'other-grant',
])
expect((await db.select().from(authSchema.oauthAccessToken)).map((row) => row.id).sort()).toEqual([
'access-2',
'other-access',
])
const [refresh] = await db
.select()
.from(authSchema.oauthRefreshToken)
.where(eq(authSchema.oauthRefreshToken.id, 'refresh-1'))
expect(refresh.revoked?.toISOString()).toBe('2026-07-29T12:00:00.000Z')
await expect(
createAgentOAuthGateway().revokeGrant(db, {
userId,
grantId: 'grant-1',
now: new Date('2026-07-29T12:30:00.000Z'),
}),
).resolves.toBe(true)
expect(await db.select().from(authSchema.oauthConsent)).toHaveLength(0)
expect(await db.select().from(authSchema.oauthAccessToken)).toHaveLength(0)
const liveRefreshes = await db
.select()
.from(authSchema.oauthRefreshToken)
.where(isNull(authSchema.oauthRefreshToken.revoked))
expect(liveRefreshes.map((row) => row.id).sort()).toEqual(['other-refresh', 'refresh-2'])
expect(liveRefreshes).toHaveLength(0)
})
it('records and detects JWT access-token revocation by jti', async () => {
const { db } = await createTestApp()
const payload = Buffer.from(
JSON.stringify({ jti: 'token-1', client_id: CLIENT_ID, exp: Math.floor(Date.now() / 1000) + 60 }),
).toString('base64url')
const token = `e30.${payload}.signature`
const gateway = createAgentOAuthGateway()
await gateway.revokeJwtAccessToken(db, token)
await expect(gateway.isJwtAccessTokenRevoked(db, 'token-1')).resolves.toBe(true)
await expect(gateway.isJwtAccessTokenRevoked(db, 'unknown')).resolves.toBe(false)
})
})
async function insertClient(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
clientId: string,
name: string,
referenceId?: string,
) {
await db.insert(authSchema.oauthClient).values({
id: clientId,
clientId,
clientSecret: null,
disabled: false,
skipConsent: false,
enableEndSession: false,
subjectType: 'public',
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
name,
uri: 'https://flareauth.example',
redirectUris: JSON.stringify(['https://flareauth.example/callback']),
tokenEndpointAuthMethod: 'none',
grantTypes: JSON.stringify(['authorization_code', 'refresh_token']),
responseTypes: JSON.stringify(['code']),
public: true,
type: 'native',
requirePKCE: true,
referenceId,
})
}
async function insertUserAndOrg(db: Awaited<ReturnType<typeof createTestApp>>['db'], userId: string, orgId: string) {
await db.insert(authSchema.user).values({
id: userId,
@@ -376,7 +174,3 @@ async function insertUserAndOrg(db: Awaited<ReturnType<typeof createTestApp>>['d
await db.insert(authSchema.organization).values({ id: orgId, name: 'OAuth Org', slug: orgId })
await db.insert(authSchema.member).values({ id: `${orgId}-member`, organizationId: orgId, userId, role: 'owner' })
}
function hashStoredToken(token: string): string {
return createHash('sha256').update(token).digest('base64url')
}
+95 -152
View File
@@ -1,119 +1,94 @@
import { createHash } from 'node:crypto'
import {
AGENT_OAUTH_CLIENT_ID,
AGENT_OAUTH_CLIENT_NAME,
AGENT_OAUTH_SCOPES,
RESTISH_OAUTH_REDIRECT_URIS,
} from '@shared/agent-oauth'
import { type AuthorizationScope, isAuthorizationScope } from '@shared/authorization'
import { and, eq, gt, inArray, isNull } from 'drizzle-orm'
import { oauthAccessToken, oauthClient, oauthConsent, oauthRefreshToken, user as userTable } from '../../db/auth-schema'
import { decodeJwt } from 'jose'
import {
oauthAccessToken,
oauthClient,
oauthConsent,
oauthJwtRevocation,
oauthRefreshToken,
} from '../../db/auth-schema'
import { executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
import type { AgentOAuthGateway, AgentOAuthGrant } from '../../usecases/ports'
import type { AgentOAuthClient, AgentOAuthGateway } from '../../usecases/ports'
export function createAgentOAuthGateway(): AgentOAuthGateway {
return {
async ensureSystemClient(db) {
const now = new Date()
const row = {
id: AGENT_OAUTH_CLIENT_ID,
clientId: AGENT_OAUTH_CLIENT_ID,
clientSecret: null,
disabled: false,
skipConsent: false,
enableEndSession: false,
subjectType: 'public',
scopes: JSON.stringify([...AGENT_OAUTH_SCOPES]),
userId: null,
createdAt: now,
updatedAt: now,
name: AGENT_OAUTH_CLIENT_NAME,
uri: null,
icon: null,
contacts: null,
tos: null,
policy: null,
softwareId: 'zpan-agent',
softwareVersion: null,
softwareStatement: null,
redirectUris: JSON.stringify([...RESTISH_OAUTH_REDIRECT_URIS]),
postLogoutRedirectUris: null,
tokenEndpointAuthMethod: 'none',
grantTypes: JSON.stringify(['authorization_code', 'refresh_token']),
responseTypes: JSON.stringify(['code']),
public: true,
type: 'native',
requirePKCE: true,
referenceId: 'system',
metadata: JSON.stringify({ systemManaged: true }),
}
await db
.insert(oauthClient)
.values(row)
.onConflictDoUpdate({
target: oauthClient.clientId,
set: {
disabled: false,
scopes: row.scopes,
updatedAt: now,
redirectUris: row.redirectUris,
tokenEndpointAuthMethod: row.tokenEndpointAuthMethod,
grantTypes: row.grantTypes,
responseTypes: row.responseTypes,
public: true,
type: row.type,
requirePKCE: true,
metadata: row.metadata,
},
async findClient(db, clientId) {
const [row] = await db
.select({
clientId: oauthClient.clientId,
name: oauthClient.name,
disabled: oauthClient.disabled,
redirectUris: oauthClient.redirectUris,
responseTypes: oauthClient.responseTypes,
scopes: oauthClient.scopes,
referenceId: oauthClient.referenceId,
})
.from(oauthClient)
.where(eq(oauthClient.clientId, clientId))
.limit(1)
if (!row || row.referenceId === 'system') return null
return {
clientId: row.clientId,
clientName: row.name || row.clientId,
disabled: row.disabled === true,
redirectUris: parseStringArray(row.redirectUris),
responseTypes: parseStringArray(row.responseTypes),
scopes: parseStringArray(row.scopes),
} satisfies AgentOAuthClient
},
async assertLiveGrant(db, input) {
const orgId = input.orgId
if (!orgId) throw new Error('agent_oauth_workspace_required')
if (input.clientId !== AGENT_OAUTH_CLIENT_ID) throw new Error('agent_oauth_client_denied')
const requestedScopes = input.scopes.filter(isAuthorizationScope)
const consent = await findConsent(db, input.userId, input.clientId, orgId)
if (!consent) throw new Error('agent_oauth_grant_revoked')
const grantedScopes = parseScopes(consent.scopes).filter(isAuthorizationScope)
if (!requestedScopes.every((scope) => grantedScopes.includes(scope))) throw new Error('agent_oauth_scope_denied')
},
async verifyAccessToken(db, token) {
async listRegisteredApplications(db) {
const rows = await db
.select({
userId: oauthAccessToken.userId,
clientId: oauthAccessToken.clientId,
orgId: oauthAccessToken.referenceId,
scopes: oauthAccessToken.scopes,
clientId: oauthClient.clientId,
name: oauthClient.name,
uri: oauthClient.uri,
redirectUris: oauthClient.redirectUris,
grantTypes: oauthClient.grantTypes,
scopes: oauthClient.scopes,
disabled: oauthClient.disabled,
createdAt: oauthClient.createdAt,
referenceId: oauthClient.referenceId,
})
.from(oauthAccessToken)
.innerJoin(userTable, eq(userTable.id, oauthAccessToken.userId))
.innerJoin(oauthClient, eq(oauthClient.clientId, oauthAccessToken.clientId))
.where(
and(
eq(oauthAccessToken.token, hashStoredToken(token)),
eq(oauthAccessToken.clientId, AGENT_OAUTH_CLIENT_ID),
gt(oauthAccessToken.expiresAt, new Date()),
eq(oauthClient.disabled, false),
eq(userTable.banned, false),
),
)
.limit(1)
const result = rows[0]
if (!result?.userId || !result.orgId) return null
const scopes = parseScopes(result.scopes).filter(isAuthorizationScope)
const consent = await findConsent(db, result.userId, result.clientId, result.orgId)
if (!consent) return null
const grantedScopes = parseScopes(consent.scopes).filter(isAuthorizationScope)
return {
grantId: consent.id,
userId: result.userId,
orgId: result.orgId,
clientId: result.clientId,
scopes: scopes.filter((scope) => grantedScopes.includes(scope)),
.from(oauthClient)
return rows
.filter((row) => row.referenceId !== 'system')
.map((row) => ({
clientId: row.clientId,
name: row.name || row.clientId,
uri: row.uri,
redirectUris: parseStringArray(row.redirectUris),
grantTypes: parseStringArray(row.grantTypes),
scopes: parseStringArray(row.scopes),
disabled: row.disabled === true,
createdAt: toIso(row.createdAt),
}))
},
async revokeJwtAccessToken(db, token) {
const payload = decodeJwt(token)
if (typeof payload.jti !== 'string' || typeof payload.client_id !== 'string' || typeof payload.exp !== 'number') {
throw new Error('invalid_oauth_jwt_revocation')
}
await db
.insert(oauthJwtRevocation)
.values({
id: payload.jti,
clientId: payload.client_id,
expiresAt: new Date(payload.exp * 1000),
createdAt: new Date(),
})
.onConflictDoNothing({ target: oauthJwtRevocation.id })
},
async isJwtAccessTokenRevoked(db, tokenId) {
const [row] = await db
.select({ id: oauthJwtRevocation.id })
.from(oauthJwtRevocation)
.where(and(eq(oauthJwtRevocation.id, tokenId), gt(oauthJwtRevocation.expiresAt, new Date())))
.limit(1)
return Boolean(row)
},
async listGrants(db, userId) {
@@ -121,6 +96,7 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
.select({
id: oauthConsent.id,
clientId: oauthConsent.clientId,
clientName: oauthClient.name,
userId: oauthConsent.userId,
orgId: oauthConsent.referenceId,
scopes: oauthConsent.scopes,
@@ -128,16 +104,18 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
lastUsedAt: oauthConsent.lastUsedAt,
})
.from(oauthConsent)
.where(and(eq(oauthConsent.userId, userId), eq(oauthConsent.clientId, AGENT_OAUTH_CLIENT_ID)))
return rows.flatMap((row): AgentOAuthGrant[] => {
.innerJoin(oauthClient, eq(oauthClient.clientId, oauthConsent.clientId))
.where(eq(oauthConsent.userId, userId))
return rows.flatMap((row) => {
if (!row.userId || !row.orgId) return []
return [
{
id: row.id,
clientId: row.clientId,
clientName: row.clientName || row.clientId,
userId: row.userId,
orgId: row.orgId,
scopes: parseScopes(row.scopes).filter(isAuthorizationScope),
scopes: parseScopes(row.scopes),
createdAt: toIso(row.createdAt),
lastUsedAt: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
},
@@ -145,22 +123,8 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
})
},
async recordGrantUse(db, input) {
await db
.update(oauthConsent)
.set({ lastUsedAt: input.now })
.where(
and(
eq(oauthConsent.id, input.grantId),
eq(oauthConsent.userId, input.userId),
eq(oauthConsent.referenceId, input.orgId),
eq(oauthConsent.clientId, AGENT_OAUTH_CLIENT_ID),
),
)
},
async revokeGrant(db, input) {
const grants = await db
const [grant] = await db
.select({
id: oauthConsent.id,
clientId: oauthConsent.clientId,
@@ -168,15 +132,8 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
referenceId: oauthConsent.referenceId,
})
.from(oauthConsent)
.where(
and(
eq(oauthConsent.id, input.grantId),
eq(oauthConsent.userId, input.userId),
eq(oauthConsent.clientId, AGENT_OAUTH_CLIENT_ID),
),
)
.where(and(eq(oauthConsent.id, input.grantId), eq(oauthConsent.userId, input.userId)))
.limit(1)
const grant = grants[0]
if (!grant?.userId || !grant.referenceId) return false
const refreshRows = await db
.select({ id: oauthRefreshToken.id })
@@ -210,23 +167,6 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
}
}
async function findConsent(db: Database, userId: string, clientId: string, orgId: string) {
const rows = await db
.select({ id: oauthConsent.id, scopes: oauthConsent.scopes })
.from(oauthConsent)
.innerJoin(userTable, eq(userTable.id, oauthConsent.userId))
.where(
and(
eq(oauthConsent.userId, userId),
eq(oauthConsent.clientId, clientId),
eq(oauthConsent.referenceId, orgId),
eq(userTable.banned, false),
),
)
.limit(1)
return rows[0] ?? null
}
function parseScopes(value: string | string[] | null): AuthorizationScope[] {
if (Array.isArray(value)) return value.filter(isAuthorizationScope)
if (!value) return []
@@ -236,12 +176,15 @@ function parseScopes(value: string | string[] | null): AuthorizationScope[] {
: []
}
function toIso(value: Date | number | string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) throw new Error('invalid_agent_oauth_date')
return date.toISOString()
function parseStringArray(value: string | string[] | null): string[] {
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string')
if (!value) return []
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []
}
function hashStoredToken(token: string): string {
return createHash('sha256').update(token).digest('base64url')
function toIso(value: Date | number | string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) throw new Error('invalid_oauth_date')
return date.toISOString()
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { inArray } from 'drizzle-orm'
import { apikey } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
const WORKSPACE_TEMPLATES = [ApiKeyTemplate.IHOST, ApiKeyTemplate.REMOTE_DOWNLOAD, ApiKeyTemplate.AGENT]
const WORKSPACE_TEMPLATES = [ApiKeyTemplate.IHOST, ApiKeyTemplate.REMOTE_DOWNLOAD]
export function scopeForApiKey(configId: string, metadata: unknown): ApiKeyScope | null {
const scope = parseApiKeyScope(metadata)
+3 -164
View File
@@ -1,31 +1,16 @@
import { defaultKeyHasher } from '@better-auth/api-key'
import {
AGENT_GRANTABLE_API_KEY_SCOPES,
API_KEY_TEMPLATES,
type ApiKeyPermissions,
ApiKeyTemplate,
type ApiKeyTemplate as ApiKeyTemplateId,
apiKeyMetadata,
} from '@shared/api-key-templates'
import {
type AuthorizationScope,
authorizationScope,
hasAuthorizationScope,
permissionScopes,
scopePermissions,
} from '@shared/authorization'
import type { AgentApiKey, AgentGrantableScope } from '@shared/schemas'
import { and, desc, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { apikey, organization } from '../../db/auth-schema'
import { executeWriteTransaction } from '../../db/transaction'
import { authorizationScope, hasAuthorizationScope } from '@shared/authorization'
import { eq } from 'drizzle-orm'
import { apikey } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
import { type ApiKeyAuth, type ApiKeyGateway, ApiKeyRateLimitError, type VerifiedApiKey } from '../../usecases/ports'
import { scopeForApiKey } from './api-key-scopes'
const AGENT_API_KEY_PREFIX = 'zpan_agent_'
const AGENT_GRANTABLE_SCOPE_SET = new Set<AuthorizationScope>(AGENT_GRANTABLE_API_KEY_SCOPES)
type VerifyApiKeyResult = {
valid: boolean
error: { message: string; code: string; details?: { tryAgainIn?: number } } | null
@@ -72,155 +57,9 @@ export function createApiKeyGateway(): ApiKeyGateway {
hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope) {
return hasAuthorizationScope(permissions, scope)
},
async listAgentApiKeys(db, userId, orgId, now) {
const rows = await listAgentRows(db, userId, orgId)
return rows.map((row) => toAgentApiKeyDTO(row, now))
},
async getAgentApiKey(db, userId, orgId, keyId, now) {
const row = await getAgentRow(db, userId, orgId, keyId)
return row ? toAgentApiKeyDTO(row, now) : null
},
async issueAgentApiKey(db, input) {
const now = new Date()
const id = crypto.randomUUID()
const key = `${AGENT_API_KEY_PREFIX}${nanoid(48)}`
const hashedKey = await defaultKeyHasher(key)
const insert = db.insert(apikey).values({
id,
configId: ApiKeyTemplate.AGENT,
name: input.name,
start: key.slice(0, AGENT_API_KEY_PREFIX.length + 6),
referenceId: input.userId,
prefix: AGENT_API_KEY_PREFIX,
key: hashedKey,
enabled: true,
rateLimitEnabled: true,
rateLimitTimeWindow: 60_000,
rateLimitMax: 600,
requestCount: 0,
expiresAt: input.expiresAt,
createdAt: now,
updatedAt: now,
permissions: JSON.stringify(scopePermissions(input.scopes)),
metadata: JSON.stringify(apiKeyMetadata({ mode: 'workspace', orgId: input.orgId })),
})
const revoke = input.revokeKeyId
? db.update(apikey).set({ enabled: false, updatedAt: now }).where(eq(apikey.id, input.revokeKeyId))
: null
await executeWriteTransaction(db, revoke ? [insert, revoke] : [insert])
const row = await getAgentRow(db, input.userId, input.orgId, id)
if (!row) throw new Error('agent_api_key_create_failed')
return { key, item: toAgentApiKeyDTO(row, now) }
},
async revokeAgentApiKey(db, keyId) {
await db.update(apikey).set({ enabled: false, updatedAt: new Date() }).where(eq(apikey.id, keyId))
},
}
}
type AgentApiKeyRow = {
id: string
name: string | null
permissions: string | null
metadata: string | null
enabled: boolean
createdAt: Date | number | string
expiresAt: Date | number | string | null
lastRequest: Date | number | string | null
workspaceName: string | null
}
async function listAgentRows(db: Database, userId: string, orgId: string): Promise<AgentApiKeyRow[]> {
const rows = await db
.select({
id: apikey.id,
name: apikey.name,
permissions: apikey.permissions,
metadata: apikey.metadata,
enabled: apikey.enabled,
createdAt: apikey.createdAt,
expiresAt: apikey.expiresAt,
lastRequest: apikey.lastRequest,
workspaceName: organization.name,
})
.from(apikey)
.leftJoin(organization, eq(organization.id, orgId))
.where(and(eq(apikey.configId, ApiKeyTemplate.AGENT), eq(apikey.referenceId, userId)))
.orderBy(desc(apikey.createdAt))
return rows.filter((row) => parseWorkspaceMetadata(row.metadata)?.orgId === orgId)
}
async function getAgentRow(db: Database, userId: string, orgId: string, keyId: string): Promise<AgentApiKeyRow | null> {
const rows = await db
.select({
id: apikey.id,
name: apikey.name,
permissions: apikey.permissions,
metadata: apikey.metadata,
enabled: apikey.enabled,
createdAt: apikey.createdAt,
expiresAt: apikey.expiresAt,
lastRequest: apikey.lastRequest,
workspaceName: organization.name,
})
.from(apikey)
.leftJoin(organization, eq(organization.id, orgId))
.where(and(eq(apikey.id, keyId), eq(apikey.configId, ApiKeyTemplate.AGENT), eq(apikey.referenceId, userId)))
.limit(1)
const row = rows[0]
return row && parseWorkspaceMetadata(row.metadata)?.orgId === orgId ? row : null
}
function toAgentApiKeyDTO(row: AgentApiKeyRow, now: Date): AgentApiKey {
const scope = parseWorkspaceMetadata(row.metadata)
if (!scope) throw new Error('agent_api_key_workspace_scope_missing')
const expiresAt = requireDate(row.expiresAt, 'agent_api_key_expiry_missing')
return {
id: row.id,
name: row.name ?? row.id,
orgId: scope.orgId,
workspaceName: row.workspaceName,
scopes: parseStoredScopes(row.permissions),
createdAt: toIso(row.createdAt),
expiresAt: expiresAt.toISOString(),
lastUsedAt: row.lastRequest ? toIso(row.lastRequest) : null,
status: !row.enabled ? 'revoked' : expiresAt <= now ? 'expired' : 'active',
}
}
function parseWorkspaceMetadata(value: string | null): { orgId: string } | null {
if (!value) return null
const parsed = JSON.parse(value) as { scope?: { mode?: unknown; orgId?: unknown } }
return parsed.scope?.mode === 'workspace' && typeof parsed.scope.orgId === 'string'
? { orgId: parsed.scope.orgId }
: null
}
function parseStoredScopes(value: string | null): AgentGrantableScope[] {
if (!value) return []
const permissions = JSON.parse(value) as ApiKeyPermissions
return permissionScopes(permissions).filter((scope): scope is AgentGrantableScope =>
AGENT_GRANTABLE_SCOPE_SET.has(scope),
)
}
function requireDate(value: Date | number | string | null, message: string): Date {
if (value === null) throw new Error(message)
const date = new Date(value)
if (Number.isNaN(date.getTime())) throw new Error(message)
return date
}
function toIso(value: Date | number | string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) throw new Error('invalid_agent_api_key_date')
return date.toISOString()
}
async function normalizeVerifiedApiKey(key: NonNullable<VerifyApiKeyResult['key']>): Promise<VerifiedApiKey | null> {
const scope = scopeForApiKey(key.configId, key.metadata)
if (!scope) return null
+51 -80
View File
@@ -1,9 +1,7 @@
import { release as osRelease } from 'node:os'
import { OpenAPIHono } from '@hono/zod-openapi'
import { Scalar } from '@scalar/hono-api-reference'
import { AGENT_OAUTH_CLIENT_ID } from '@shared/agent-oauth'
import { AGENT_API_KEY_SHORTCUT_SCOPES, AgentApiKeyShortcut } from '@shared/api-key-templates'
import { AuthorizationScope } from '@shared/authorization'
import { AGENT_OAUTH_SCOPE_DESCRIPTIONS, AGENT_OAUTH_SCOPES } from '@shared/agent-oauth'
import type { Context } from 'hono'
import { cors } from 'hono/cors'
import type { Auth } from './auth'
@@ -12,8 +10,8 @@ import { createDeps } from './composition'
import { isPotentialWebDavPublicRequest, isWebDavPublicRequest } from './domain/webdav-public-url'
import { adminOverview } from './http/admin-overview'
import { adminStats } from './http/admin-stats'
import agentApiKeys from './http/agent-api-keys'
import { agentOAuthGrants } from './http/agent-oauth-grants'
import { ARAZZO_DOCUMENT_PATH, ARAZZO_MEDIA_TYPE, createArazzoDocument } from './http/arazzo'
import { serveAvatarBlob } from './http/avatar-blobs'
import backgroundJobs from './http/background-jobs'
import { configz } from './http/configz'
@@ -24,6 +22,7 @@ import ihostConfig from './http/image-hosting/config'
import ihost from './http/image-hosting/images'
import internal from './http/internal'
import { notifications } from './http/notifications'
import { oauthResourceScopes } from './http/oauth-resource-scopes'
import objects from './http/objects'
import { adminQuotas, userQuotas } from './http/quotas'
import redirect from './http/redirect'
@@ -137,7 +136,25 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
app.on(['POST', 'GET', 'HEAD'], '/api/auth/*', async (c) => {
const a = c.get('auth')
return a.handler(c.req.raw)
const revokeRequest = c.req.path === '/api/auth/oauth2/revoke' ? c.req.raw.clone() : null
const response = await a.handler(c.req.raw)
if (revokeRequest && response.status === 400) {
const error = (await response
.clone()
.json()
.catch(() => null)) as { error?: string } | null
if (error?.error === 'unsupported_token_type') {
const token = (await revokeRequest.formData()).get('token')
if (typeof token === 'string') {
await c.get('deps').agentOAuth.revokeJwtAccessToken(c.get('platform').db, token)
return new Response(null, {
status: 200,
headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' },
})
}
}
}
return response
})
app.on(['GET', 'HEAD'], '/.well-known/oauth-authorization-server/api/auth', async (c) => {
@@ -155,21 +172,32 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
resource: `${origin}/api`,
authorization_servers: [authorizationServer],
bearer_methods_supported: ['header'],
scopes_supported: [
'objects:read',
'objects:create',
'objects:update',
'objects:delete',
'shares:read',
'shares:create',
'shares:delete',
'quota:read',
'storage-usage:read',
],
scopes_supported: AGENT_OAUTH_SCOPES.filter((scope) => scope.includes(':')),
dpop_signing_alg_values_supported: ['ES256', 'EdDSA'],
resource_name: 'ZPan API',
})
})
app.get('/api', (c) => {
c.header(
'Link',
[
'</api/openapi.json>; rel="service-desc"; type="application/openapi+json"',
`<${ARAZZO_DOCUMENT_PATH}>; rel="describedby"; type="application/vnd.oai.workflows+json"`,
].join(', '),
)
return c.json({ name: 'ZPan API', openapi: '/api/openapi.json', workflows: ARAZZO_DOCUMENT_PATH })
})
app.on(['GET', 'HEAD'], ARAZZO_DOCUMENT_PATH, (c) => {
const headers = {
'Cache-Control': 'public, max-age=300',
'Content-Type': ARAZZO_MEDIA_TYPE,
}
if (c.req.method === 'HEAD') return c.newResponse(null, 200, headers)
return c.newResponse(JSON.stringify(createArazzoDocument(new URL(c.req.url).origin)), 200, headers)
})
// Global OpenAPI document. Aggregates every route defined with `.openapi()`
// across all mounted sub-apps — a route appears here as soon as its resource is
// converted to OpenAPIHono, no curation needed. better-auth endpoints (incl. the
@@ -179,6 +207,10 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
openapi: '3.1.0',
info: { title: 'ZPan API', version: '0.1.0' },
servers: [{ url: '/', description: 'Current ZPan origin' }],
externalDocs: {
description: 'Machine-readable API workflows (Arazzo 1.1)',
url: ARAZZO_DOCUMENT_PATH,
},
// Top-level tag order + descriptions; Scalar groups operations by these.
tags: [
{ name: 'Objects', description: 'Files and folders, including S3 multipart upload sessions' },
@@ -218,14 +250,12 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
},
},
},
agentApiKey: { type: 'http', scheme: 'bearer', description: 'Workspace-scoped Agent API key' },
}
doc.components.schemas = {
...(authDoc.components?.schemas as typeof doc.components.schemas),
...doc.components.schemas,
}
Object.assign(doc, {
'x-cli-config': restishCliConfig(),
'x-zpan-discovery': {
oauthAuthorizationServer: '/.well-known/oauth-authorization-server/api/auth',
oauthProtectedResource: '/.well-known/oauth-protected-resource/api',
@@ -307,6 +337,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
// /s/:token is intentionally left for the SPA landing page.
app.route('/api/shares', publicShares)
app.route('/api/configz', configz)
app.route('/api/oauth-resource-scopes', oauthResourceScopes)
// Self-hosted avatar blobs (CF + AVATARS R2 binding, no AVATARS_PUBLIC_URL). Public.
app.get('/api/avatar-blobs/:scope/:id', serveAvatarBlob)
app.route('/r', redirect)
@@ -326,7 +357,6 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
app.route('/api/objects', objects)
app.route('/api/shares', authedShares)
app.route('/api/trash', trash)
app.route('/api/workspaces', agentApiKeys)
app.route('/api', agentOAuthGrants)
app.route('/api/teams', teams)
app.route('/api/teams', adminTeams)
@@ -432,66 +462,7 @@ function getCorsOrigins(platform: Platform): Set<string> {
}
function agentScopeDescriptions(): Record<string, string> {
return {
[AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects',
[AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects',
[AuthorizationScope.OBJECTS_UPDATE]: 'Rename, move, and copy objects',
[AuthorizationScope.OBJECTS_DELETE]: 'Soft-delete objects',
[AuthorizationScope.SHARES_READ]: 'List and inspect shares',
[AuthorizationScope.SHARES_CREATE]: 'Create public shares',
[AuthorizationScope.SHARES_DELETE]: 'Revoke shares',
[AuthorizationScope.QUOTA_READ]: 'Inspect workspace quota',
[AuthorizationScope.STORAGE_USAGE_READ]: 'Inspect workspace storage usage',
}
}
function restishCliConfig() {
const oauthCredential = (scopes: readonly AuthorizationScope[]) => ({
auth: {
type: 'oauth-authorization-code',
params: {
authorize_url: '/api/auth/oauth2/authorize',
token_url: '/api/auth/oauth2/token',
client_id: AGENT_OAUTH_CLIENT_ID,
scopes: ['openid', 'offline_access', ...scopes].join(' '),
redirect_path: '/callback',
},
},
satisfies: [...scopes],
})
return {
profiles: {
default: {
credentials: {
agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.READER]),
},
},
reader: {
credentials: {
agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.READER]),
},
},
'file-manager': {
credentials: {
agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.FILE_MANAGER]),
},
},
publisher: {
credentials: {
agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.PUBLISHER]),
},
},
ci: {
credentials: {
agentApiKey: {
auth: { type: 'bearer', params: { token: 'env:ZPAN_AGENT_API_KEY' } },
satisfies: [...AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.FILE_MANAGER]],
},
},
},
},
}
return { ...AGENT_OAUTH_SCOPE_DESCRIPTIONS }
}
export type AppType = ReturnType<typeof createApp>
@@ -537,5 +508,5 @@ export type AdminAuditRoute = typeof adminAudit
export type AdminOverviewRoute = typeof adminOverview
export type AdminStatsRoute = typeof adminStats
export type StorageUsageRoute = typeof storageUsage
export type AgentApiKeysRoute = typeof agentApiKeys
export type AgentOAuthGrantsRoute = typeof agentOAuthGrants
export type OAuthResourceScopesRoute = typeof oauthResourceScopes
+302 -8
View File
@@ -1,5 +1,8 @@
import { createHash } from 'node:crypto'
import { isPersonalOrgLike } from '@shared/org-slugs'
import { deriveDpopAth } from 'better-auth/oauth2'
import { eq } from 'drizzle-orm'
import { exportJWK, generateKeyPair, SignJWT } from 'jose'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createInviteRepo } from './adapters/repos/invite.js'
import { createSiteInvitationRepo } from './adapters/repos/site-invitations.js'
@@ -8,7 +11,7 @@ import { createAuth } from './auth.js'
import * as authSchema from './db/auth-schema.js'
import * as schema from './db/schema.js'
import { inviteCodes, siteInvitations } from './db/schema.js'
import { createTestApp, seedProLicense } from './test/setup.js'
import { adminHeaders, createTestApp, seedProLicense } from './test/setup.js'
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
@@ -527,6 +530,18 @@ describe('buildVerificationEmailHtml — via send-verification-email with email_
describe('loadProviderConfigs — createAuth with OIDC provider pre-configured', () => {
it('createAuth succeeds when a valid enabled OIDC provider config is present', async () => {
const ctx = await createTestApp()
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
userinfo_endpoint: 'https://auth.example.com/userinfo',
jwks_uri: 'https://auth.example.com/jwks',
}),
),
)
const oidcConfig = JSON.stringify({
providerId: 'my-oidc',
type: 'oidc',
@@ -666,7 +681,7 @@ describe('loadProviderConfigs — builtin social provider resolution', () => {
expect(res.status).not.toBe(200)
})
it('createAuth runs exactly one DB query during init (no per-provider I/O)', async () => {
it('createAuth initializes provider config and the two OAuth resources with three DB reads', async () => {
const ctx = await createTestApp()
let selectCalls = 0
const countingDb = new Proxy(ctx.db, {
@@ -677,7 +692,7 @@ describe('loadProviderConfigs — builtin social provider resolution', () => {
},
})
await createAuth(countingDb as typeof ctx.db, 'test-secret', 'http://localhost:3000')
expect(selectCalls).toBe(1)
expect(selectCalls).toBe(3)
})
it('createAuth resolves better-auth $context before returning', async () => {
@@ -695,7 +710,272 @@ describe('loadProviderConfigs — builtin social provider resolution', () => {
})
describe('Agent OAuth consent guards', () => {
it('issues an authorization code after full consent for the managed PKCE client', async () => {
it('publishes the external resource discovery contract at the exact API URL', async () => {
const ctx = await createTestApp()
const resource = await ctx.app.request('http://localhost:3000/api')
const metadata = await ctx.app.request('http://localhost:3000/.well-known/oauth-protected-resource/api')
const authorizationServer = await ctx.app.request(
'http://localhost:3000/.well-known/oauth-authorization-server/api/auth',
)
expect(resource.status).toBe(200)
expect(resource.headers.get('link')).toBe(
[
'</api/openapi.json>; rel="service-desc"; type="application/openapi+json"',
'</api/workflows.arazzo.json>; rel="describedby"; type="application/vnd.oai.workflows+json"',
].join(', '),
)
await expect(metadata.json()).resolves.toMatchObject({
resource: 'http://localhost:3000/api',
authorization_servers: ['http://localhost:3000/api/auth'],
})
await expect(authorizationServer.json()).resolves.toMatchObject({
registration_endpoint: 'http://localhost:3000/api/auth/oauth2/register',
grant_types_supported: expect.arrayContaining([
'urn:ietf:params:oauth:grant-type:jwt-bearer',
'urn:ietf:params:oauth:grant-type:token-exchange',
]),
dpop_signing_alg_values_supported: expect.any(Array),
})
})
it('dynamically registers an external resource client without hard-coded identity', 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: 'External Resource 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: 'https://broker.example.com/api/auth/jwks',
}),
})
const body = (await res.json()) as Record<string, unknown>
expect(res.status, JSON.stringify(body)).toBe(201)
expect(body).toMatchObject({
client_id: expect.any(String),
client_secret: expect.any(String),
token_endpoint_auth_method: 'client_secret_basic',
})
expect(String(body.scope).split(' ')).toEqual(expect.arrayContaining(['openid', 'offline_access', 'objects:read']))
const applicationsResponse = await ctx.app.request('/api/site/auth-providers', {
headers: await adminHeaders(ctx.app),
})
const applications = (await applicationsResponse.json()) as {
registeredApplications: Array<{ clientId: string; name: string }>
}
expect(applicationsResponse.status).toBe(200)
expect(applications.registeredApplications).toEqual(
expect.arrayContaining([
expect.objectContaining({
clientId: body.client_id,
name: 'External Resource Broker',
}),
]),
)
})
it('issues a DPoP API token through JWT bearer and token exchange grants', async () => {
const ctx = await createTestApp()
const { privateKey: agentPrivateKey, publicKey: agentPublicKey } = await generateKeyPair('ES256')
const agentPublicJwk = { ...(await exportJWK(agentPublicKey)), kid: 'agent-key', use: 'sig', alg: 'ES256' }
const getJwks = ctx.auth.api.getJwks
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const url = input instanceof Request ? input.url : String(input)
if (url === 'https://broker.example.com/api/auth/jwks') {
return Response.json({ keys: [agentPublicJwk] })
}
if (url === 'http://localhost:3000/api/auth/jwks') {
return Response.json(await getJwks())
}
throw new Error(`Unexpected fetch: ${url}`)
}),
)
const registration = await ctx.app.request('http://localhost:3000/api/auth/oauth2/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'External Resource 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: 'https://broker.example.com/api/auth/jwks',
}),
})
const registered = (await registration.json()) as { client_id: string; client_secret: string }
expect(registration.status).toBe(201)
const signUpResponse = await signUp(ctx, 'external-resource@example.com')
const cookie = signUpResponse.headers
.getSetCookie()
.map((value) => value.split(';', 1)[0])
.join('; ')
const verifier = 'external-resource-verifier-with-sufficient-entropy-1234567890'
const challenge = createHash('sha256').update(verifier).digest('base64url')
const redirectUri = 'https://broker.example.com/api/account-connections/oauth/callback'
const scope = 'openid offline_access objects:read quota:read'
const authorizeParams = new URLSearchParams({
client_id: registered.client_id,
redirect_uri: redirectUri,
response_type: 'code',
resource: 'http://localhost:3000/api',
scope,
state: 'external-resource',
code_challenge: challenge,
code_challenge_method: 'S256',
})
const authorize = await ctx.app.request(
`http://localhost:3000/api/auth/oauth2/authorize?${authorizeParams.toString()}`,
{ headers: { Cookie: cookie, Origin: 'http://localhost:3000' } },
)
const consentLocation = authorize.headers.get('location')
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
const consent = await ctx.app.request('http://localhost:3000/api/auth/oauth2/consent', {
method: 'POST',
headers: { Cookie: cookie, Origin: 'http://localhost:3000', 'Content-Type': 'application/json' },
body: JSON.stringify({
accept: true,
oauth_query: consentLocation?.slice(consentLocation.indexOf('?') + 1),
}),
})
const consentBody = (await consent.json()) as { url: string }
expect(consent.status).toBe(200)
const code = new URL(consentBody.url).searchParams.get('code')
expect(code).toBeTruthy()
const tokenEndpoint = 'http://localhost:3000/api/auth/oauth2/token'
const basic = `Basic ${Buffer.from(`${registered.client_id}:${registered.client_secret}`).toString('base64')}`
const subjectResponse = await ctx.app.request(tokenEndpoint, {
method: 'POST',
headers: { Authorization: basic, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code!,
redirect_uri: redirectUri,
code_verifier: verifier,
resource: 'http://localhost:3000/api',
}).toString(),
})
const subject = (await subjectResponse.json()) as { access_token: string }
expect(subjectResponse.status).toBe(200)
const now = Math.floor(Date.now() / 1000)
const assertion = await new SignJWT({})
.setProtectedHeader({ typ: 'JWT', alg: 'ES256', kid: 'agent-key' })
.setIssuer('https://broker.example.com/api/auth')
.setSubject('agent-123')
.setAudience(tokenEndpoint)
.setIssuedAt(now)
.setExpirationTime(now + 300)
.setJti(crypto.randomUUID())
.sign(agentPrivateKey)
const actorResponse = await ctx.app.request(tokenEndpoint, {
method: 'POST',
headers: { Authorization: basic, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}).toString(),
})
const actor = (await actorResponse.json()) as { access_token: string }
expect(actorResponse.status, JSON.stringify(actor)).toBe(200)
const { privateKey: dpopPrivateKey, publicKey: dpopPublicKey } = await generateKeyPair('ES256')
const dpopPublicJwk = await exportJWK(dpopPublicKey)
const exchangeProof = await new SignJWT({
htm: 'POST',
htu: tokenEndpoint,
})
.setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: dpopPublicJwk })
.setIssuedAt()
.setJti(crypto.randomUUID())
.sign(dpopPrivateKey)
const exchangeResponse = await ctx.app.request(tokenEndpoint, {
method: 'POST',
headers: {
Authorization: basic,
DPoP: exchangeProof,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: subject.access_token,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
actor_token: actor.access_token,
actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
resource: 'http://localhost:3000/api',
scope: 'objects:read quota:read',
}).toString(),
})
const exchanged = (await exchangeResponse.json()) as { access_token: string; token_type: string; scope: string }
expect(exchangeResponse.status).toBe(200)
expect(exchanged).toMatchObject({ token_type: 'DPoP', scope: 'objects:read quota:read' })
const apiUrl = 'http://localhost:3000/api/objects'
const apiProof = await new SignJWT({
htm: 'GET',
htu: apiUrl,
ath: await deriveDpopAth(exchanged.access_token),
})
.setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: dpopPublicJwk })
.setIssuedAt()
.setJti(crypto.randomUUID())
.sign(dpopPrivateKey)
const apiResponse = await ctx.app.request(apiUrl, {
headers: { Authorization: `DPoP ${exchanged.access_token}`, DPoP: apiProof },
})
expect(apiResponse.status).toBe(200)
const revokeResponse = await ctx.app.request('http://localhost:3000/api/auth/oauth2/revoke', {
method: 'POST',
headers: { Authorization: basic, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: exchanged.access_token,
token_type_hint: 'access_token',
}).toString(),
})
expect(revokeResponse.status).toBe(200)
const revokedProof = await new SignJWT({
htm: 'GET',
htu: apiUrl,
ath: await deriveDpopAth(exchanged.access_token),
})
.setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: dpopPublicJwk })
.setIssuedAt()
.setJti(crypto.randomUUID())
.sign(dpopPrivateKey)
const revokedResponse = await ctx.app.request(apiUrl, {
headers: { Authorization: `DPoP ${exchanged.access_token}`, DPoP: revokedProof },
})
expect(revokedResponse.status).toBe(401)
expect(revokedResponse.headers.get('www-authenticate')).toContain('DPoP')
})
it('issues an authorization code after full consent for a dynamically registered PKCE client', async () => {
const ctx = await createTestApp()
const previewOrigin = 'https://preview-zpan.example.com'
const auth = await createAuth(ctx.platform, 'test-secret', 'https://zpan-staging.example.com', [previewOrigin])
@@ -705,9 +985,23 @@ describe('Agent OAuth consent guards', () => {
.getSetCookie()
.map((value) => value.split(';', 1)[0])
.join('; ')
const registration = await app.request('/api/auth/oauth2/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Consent Test Client',
redirect_uris: ['https://broker.example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
scope: 'openid offline_access objects:read quota:read',
}),
})
const registered = (await registration.json()) as { client_id: string }
expect(registration.status).toBe(201)
const params = new URLSearchParams({
client_id: 'zpan-agent',
redirect_uri: 'http://127.0.0.1:8484/callback',
client_id: registered.client_id,
redirect_uri: 'https://broker.example.com/callback',
response_type: 'code',
scope: 'openid offline_access objects:read quota:read',
state: 'oauth-consent-test',
@@ -737,7 +1031,7 @@ describe('Agent OAuth consent guards', () => {
expect(consent.status, consentBody).toBe(200)
expect(JSON.parse(consentBody)).toMatchObject({
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8484\/callback\?code=/),
url: expect.stringMatching(/^https:\/\/broker\.example\.com\/callback\?code=/),
})
})
@@ -747,7 +1041,7 @@ describe('Agent OAuth consent guards', () => {
const res = await ctx.app.request('/api/auth/oauth2/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'zpan-agent', scope: 'objects:read' }),
body: JSON.stringify({ client_id: 'dynamic-client', scope: 'objects:read' }),
})
expect(res.status).toBe(400)
+59 -23
View File
@@ -9,6 +9,7 @@ import {
bearer,
captcha,
deviceAuthorization,
jwt,
lastLoginMethod,
openAPI,
organization,
@@ -18,6 +19,7 @@ import { genericOAuth } from 'better-auth/plugins/generic-oauth'
import { adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
import { count, eq, like } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { AGENT_OAUTH_SCOPES, JWT_BEARER_GRANT_TYPE, TOKEN_EXCHANGE_GRANT_TYPE } from '../shared/agent-oauth'
import {
API_KEY_TEMPLATES,
ApiKeyTemplate,
@@ -39,7 +41,6 @@ import {
} from '../shared/oauth-providers'
import { generateUserOrgSlug, isPersonalOrgLike } from '../shared/org-slugs'
import { createEmailGateway } from './adapters/gateways/email'
import { createAgentOAuthGateway } from './adapters/repos/agent-oauth'
import { deleteApiKeysScopedToOrganization } from './adapters/repos/api-key-scopes'
import { createAuditRepo } from './adapters/repos/audit'
import { createDownloadTokenGateway } from './adapters/repos/download-tokens'
@@ -92,6 +93,49 @@ interface ProviderConfigs {
builtin: Array<{ providerId: string; clientId: string; clientSecret: string }>
}
const EXTERNAL_RESOURCE_GRANTS = new Set([
'authorization_code',
'refresh_token',
JWT_BEARER_GRANT_TYPE,
TOKEN_EXCHANGE_GRANT_TYPE,
])
function isExternalResourceClientRegistration(body: Record<string, unknown>): boolean {
const grants = Array.isArray(body.grant_types) ? body.grant_types : []
const responses = Array.isArray(body.response_types) ? body.response_types : []
return (
body.token_endpoint_auth_method === 'client_secret_basic' &&
typeof body.jwks_uri === 'string' &&
Array.isArray(body.redirect_uris) &&
body.redirect_uris.length > 0 &&
grants.length === EXTERNAL_RESOURCE_GRANTS.size &&
grants.every((grant) => typeof grant === 'string' && EXTERNAL_RESOURCE_GRANTS.has(grant)) &&
responses.length === 1 &&
responses[0] === 'code'
)
}
async function dynamicRegistrationOrigins(request: Request): Promise<string[]> {
if (!new URL(request.url).pathname.endsWith('/oauth2/register') || request.method !== 'POST') return []
let body: Record<string, unknown>
try {
body = (await request.clone().json()) as Record<string, unknown>
} catch {
return []
}
if (!isExternalResourceClientRegistration(body)) return []
const redirectUris = body.redirect_uris as string[]
const jwksUri = body.jwks_uri as string
try {
const origins = new Set(redirectUris.map((uri) => new URL(uri).origin))
const jwksOrigin = new URL(jwksUri).origin
if (origins.size !== 1 || !origins.has(jwksOrigin)) return []
return [jwksOrigin]
} catch {
return []
}
}
// One query loads every oauth_provider_* row. Configs are snapshotted at auth
// instance creation: better-auth resolves social providers eagerly during its
// context init, so per-request dynamic loading is not possible anyway. Admin
@@ -347,20 +391,21 @@ export async function createAuth(
const systemOptionsRepo = createSystemOptionsRepo(db)
const email = createEmailGateway(systemOptionsRepo)
const providerConfigs = await loadProviderConfigs(rawDb)
const agentOAuth = createAgentOAuthGateway()
await agentOAuth.ensureSystemClient(db)
const resourceAudience = baseURL ? `${new URL(baseURL).origin}/api` : undefined
const usesNativeWebDavRateLimit = Boolean(authPlatform.getBinding(WEBDAV_RATE_LIMITER_BINDING))
const authOptions = {
database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }),
secret,
baseURL,
basePath: '/api/auth',
// Function form: better-auth merges the result with baseURL per request.
// Loopback/LAN origins are trusted automatically so self-hosted users can
// log in via 127.0.0.1 or a LAN IP without configuring TRUSTED_ORIGINS.
trustedOrigins: (request?: Request) => {
trustedOrigins: async (request?: Request) => {
const origin = request?.headers.get('origin')
const list = trustedOrigins ?? []
return origin && isLocalNetworkOrigin(origin) ? [...list, origin] : list
const registrationOrigins = request ? await dynamicRegistrationOrigins(request) : []
return [...list, ...(origin && isLocalNetworkOrigin(origin) ? [origin] : []), ...registrationOrigins]
},
advanced: {
cookiePrefix: 'zp',
@@ -438,6 +483,13 @@ export async function createAuth(
}
return
}
if (ctx.path === '/oauth2/register') {
const body = ctx.body as Record<string, unknown> | undefined
if (body && isExternalResourceClientRegistration(body)) {
body.scope = AGENT_OAUTH_SCOPES.join(' ')
}
return
}
if (ctx.path === '/oauth2/update-consent' || ctx.path === '/oauth2/delete-consent') {
throw new APIError('FORBIDDEN', {
error: 'invalid_request',
@@ -450,10 +502,6 @@ export async function createAuth(
if (!body) return
const configId = body.configId
if (typeof configId !== 'string' || !API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate)) return
if (configId === ApiKeyTemplate.AGENT) {
throw new APIError('BAD_REQUEST', { message: 'Create Agent API keys from the Agent Access API' })
}
const session = await getSessionFromCtx(ctx)
const userId = session?.user.id ?? (typeof body?.userId === 'string' ? body.userId : null)
if (!userId) throw new APIError('UNAUTHORIZED', { message: 'Unauthorized' })
@@ -630,7 +678,8 @@ export async function createAuth(
verificationUri: '/device',
validateClient: async (clientId) => clientId === LEGACY_DOWNLOADER_CLIENT_ID,
}),
oauthProvider(createAgentOAuthProviderOptions({ db, agentOAuth })),
jwt(),
oauthProvider(createAgentOAuthProviderOptions({ db, resourceAudience })),
apiKey([
{
configId: ApiKeyTemplate.IHOST,
@@ -677,19 +726,6 @@ export async function createAuth(
defaultPermissions: REMOTE_DOWNLOAD_API_KEY_PERMISSIONS,
},
},
{
configId: ApiKeyTemplate.AGENT,
references: 'user',
enableMetadata: true,
rateLimit: {
enabled: true,
timeWindow: 60_000,
maxRequests: 600,
},
permissions: {
defaultPermissions: {},
},
},
]),
],
databaseHooks: {
+13 -37
View File
@@ -1,30 +1,16 @@
import { AGENT_OAUTH_ACCESS_TOKEN_SECONDS, AGENT_OAUTH_CLIENT_ID, AGENT_OAUTH_SCOPES } from '@shared/agent-oauth'
import { AGENT_OAUTH_ACCESS_TOKEN_SECONDS, AGENT_OAUTH_SCOPES } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { describe, expect, it, vi } from 'vitest'
import type { AgentOAuthGateway } from '../usecases/ports'
import { createAgentOAuthProviderOptions } from './agent-oauth-provider'
const db = {} as never
function createGateway(): AgentOAuthGateway {
return {
ensureSystemClient: vi.fn(),
assertLiveGrant: vi.fn(),
verifyAccessToken: vi.fn(),
listGrants: vi.fn(),
recordGrantUse: vi.fn(),
revokeGrant: vi.fn(),
}
}
function createOptions(input?: {
findPersonalOrg?: (userId: string) => Promise<string | null>
getMemberRole?: (orgId: string, userId: string) => Promise<string | null>
gateway?: AgentOAuthGateway
}) {
return createAgentOAuthProviderOptions({
db,
agentOAuth: input?.gateway ?? createGateway(),
orgs: {
findPersonalOrg: input?.findPersonalOrg ?? vi.fn(async () => 'personal-org'),
getMemberRole: input?.getMemberRole ?? vi.fn(async () => 'owner'),
@@ -33,11 +19,10 @@ function createOptions(input?: {
}
describe('createAgentOAuthProviderOptions', () => {
it('configures the managed public native Agent OAuth provider contract', async () => {
it('configures a dynamic-client OAuth provider contract', async () => {
const options = createOptions()
expect(options).toMatchObject({
disableJwtPlugin: true,
loginPage: '/sign-in',
consentPage: '/settings/agent-access',
accessTokenExpiresIn: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
@@ -45,7 +30,9 @@ describe('createAgentOAuthProviderOptions', () => {
postLogin: { page: '/settings/agent-access' },
})
expect(options.scopes).toEqual([...AGENT_OAUTH_SCOPES])
expect(options.cachedTrustedClients?.has(AGENT_OAUTH_CLIENT_ID)).toBe(true)
expect(options.cachedTrustedClients).toBeUndefined()
expect(options.allowDynamicClientRegistration).toBe(true)
expect(options.allowUnauthenticatedClientRegistration).toBe(true)
await expect(options.postLogin?.shouldRedirect?.({} as never)).resolves.toBe(false)
})
@@ -109,36 +96,25 @@ describe('createAgentOAuthProviderOptions', () => {
})
})
it('adds ZPan Agent claims only for valid live grants', async () => {
const gateway = createGateway()
const options = createOptions({ gateway })
it('adds ZPan resource claims to a consent-bound access token', async () => {
const options = createOptions()
await expect(
options.customAccessTokenClaims?.({
user: { id: 'user-1' },
referenceId: 'team-org',
scopes: [AuthorizationScope.OBJECTS_READ],
metadata: {},
} as never),
).resolves.toEqual({ zpan_org_id: 'team-org', zpan_actor: 'agent_oauth' })
expect(gateway.assertLiveGrant).toHaveBeenCalledWith(db, {
userId: 'user-1',
clientId: AGENT_OAUTH_CLIENT_ID,
orgId: 'team-org',
scopes: [AuthorizationScope.OBJECTS_READ],
})
})
it('skips non-agent clients and rejects missing user or workspace context', async () => {
it('omits ZPan resource claims without user or workspace context', async () => {
const options = createOptions()
await expect(options.customAccessTokenClaims?.({ metadata: {}, scopes: [] } as never)).resolves.toEqual({})
await expect(
options.customAccessTokenClaims?.({ metadata: { client_id: 'other-client' }, scopes: [] } as never),
options.customAccessTokenClaims?.({
user: { id: 'user-1' },
scopes: [],
} as never),
).resolves.toEqual({})
await expect(
options.customAccessTokenClaims?.({ user: { id: 'user-1' }, scopes: [] } as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'Agent OAuth grant is missing workspace context' }),
})
})
})
+192 -28
View File
@@ -1,34 +1,70 @@
import type { oauthProvider } from '@better-auth/oauth-provider'
import { APIError } from 'better-auth'
import {
consumeClientAssertion,
type OAuthProviderExtension,
type oauthProvider,
type SchemaClient,
type Scope,
} from '@better-auth/oauth-provider'
import { APIError, type User } from 'better-auth'
import { createLocalJWKSet, createRemoteJWKSet, type JSONWebKeySet, jwtVerify } from 'jose'
import {
AGENT_ACTOR_RESOURCE,
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_CLIENT_ID,
AGENT_OAUTH_ACTOR_TOKEN_SECONDS,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
AGENT_OAUTH_SCOPES,
AGENT_OAUTH_STANDARD_SCOPES,
JWT_BEARER_GRANT_TYPE,
OAUTH_ACCESS_TOKEN_TYPE,
TOKEN_EXCHANGE_GRANT_TYPE,
} from '../../shared/agent-oauth'
import { isAuthorizationScope } from '../../shared/authorization'
import { createOrgRepo } from '../adapters/repos/org'
import type { Database } from '../platform/interface'
import type { AgentOAuthGateway } from '../usecases/ports'
type AgentOAuthOrgLookup = Pick<ReturnType<typeof createOrgRepo>, 'findPersonalOrg' | 'getMemberRole'>
type AgentOAuthProviderOptions = Parameters<typeof oauthProvider>[0]
export function createAgentOAuthProviderOptions(input: {
db: Database
agentOAuth: AgentOAuthGateway
resourceAudience?: string
orgs?: AgentOAuthOrgLookup
}): AgentOAuthProviderOptions {
const orgs = input.orgs ?? createOrgRepo(input.db)
const resources = input.resourceAudience
? [
{
identifier: input.resourceAudience,
name: 'ZPan API',
accessTokenTtl: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
allowedScopes: [...AGENT_OAUTH_SCOPES],
},
{
identifier: AGENT_ACTOR_RESOURCE,
name: 'ZPan Agent Actor',
accessTokenTtl: AGENT_OAUTH_ACTOR_TOKEN_SECONDS,
allowedScopes: ['openid'],
},
]
: undefined
return {
disableJwtPlugin: true,
loginPage: '/sign-in',
consentPage: '/settings/agent-access',
accessTokenExpiresIn: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
m2mAccessTokenExpiresIn: AGENT_OAUTH_ACTOR_TOKEN_SECONDS,
refreshTokenExpiresIn: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
grantTypes: ['authorization_code', 'refresh_token'],
scopes: [...AGENT_OAUTH_SCOPES],
resources,
enforcePerClientResources: false,
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
clientRegistrationRequirePKCE: true,
clientRegistrationAllowedScopes: [...AGENT_OAUTH_SCOPES],
clientRegistrationDefaultScopes: [...AGENT_OAUTH_STANDARD_SCOPES],
extensions: input.resourceAudience ? [externalResourceGrantExtension(input.resourceAudience)] : [],
advertisedMetadata: { scopes_supported: [...AGENT_OAUTH_SCOPES] },
cachedTrustedClients: new Set([AGENT_OAUTH_CLIENT_ID]),
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
@@ -40,16 +76,11 @@ export function createAgentOAuthProviderOptions(input: {
const clientScopes = scopes.filter((scope) => scope !== 'openid' && scope !== 'profile' && scope !== 'email')
const grantableScopes = new Set<string>(AGENT_OAUTH_SCOPES)
if (clientScopes.some((scope) => !grantableScopes.has(scope))) {
throw new APIError('BAD_REQUEST', { error: 'invalid_scope', error_description: 'Scope is not grantable' })
throw oauthError('invalid_scope', 'Scope is not grantable')
}
const orgId = typeof session.activeOrganizationId === 'string' ? session.activeOrganizationId : null
const selectedOrgId = orgId || (await orgs.findPersonalOrg(user.id))
if (!selectedOrgId) {
throw new APIError('BAD_REQUEST', {
error: 'invalid_request',
error_description: 'A workspace is required for Agent OAuth',
})
}
if (!selectedOrgId) throw oauthError('invalid_request', 'A workspace is required for Agent OAuth')
const role = await orgs.getMemberRole(selectedOrgId, user.id)
if (!role && selectedOrgId !== (await orgs.findPersonalOrg(user.id))) {
throw new APIError('FORBIDDEN', {
@@ -60,20 +91,8 @@ export function createAgentOAuthProviderOptions(input: {
return selectedOrgId
},
},
customAccessTokenClaims: async ({ user, referenceId, scopes, metadata }) => {
if (metadata?.client_id && metadata.client_id !== AGENT_OAUTH_CLIENT_ID) return {}
if (!user?.id || !referenceId) {
throw new APIError('BAD_REQUEST', {
error: 'invalid_grant',
error_description: 'Agent OAuth grant is missing workspace context',
})
}
await input.agentOAuth.assertLiveGrant(input.db, {
userId: user.id,
clientId: AGENT_OAUTH_CLIENT_ID,
orgId: referenceId,
scopes,
})
customAccessTokenClaims: async ({ user, referenceId }) => {
if (!user?.id || !referenceId) return {}
return {
zpan_org_id: referenceId,
zpan_actor: 'agent_oauth',
@@ -81,3 +100,148 @@ export function createAgentOAuthProviderOptions(input: {
},
}
}
function externalResourceGrantExtension(resourceAudience: string): OAuthProviderExtension {
return {
grants: {
[JWT_BEARER_GRANT_TYPE]: async ({ ctx, opts, provider }) => {
const { client } = await provider.authenticateClient()
const assertion = bodyString(ctx.body, 'assertion')
const payload = await verifyAgentAssertion(ctx, opts, client, assertion)
const subject = requiredClaim(payload.sub, 'assertion sub')
const issuer = requiredClaim(payload.iss, 'assertion iss')
return provider.issueTokens({
client,
scopes: ['openid'],
user: assertionUser(subject),
resources: [AGENT_ACTOR_RESOURCE],
accessTokenClaims: {
zpan_actor_token: true,
zpan_actor_issuer: issuer,
},
})
},
[TOKEN_EXCHANGE_GRANT_TYPE]: async ({ ctx, provider }) => {
if (!ctx.headers?.get('dpop')) throw oauthError('invalid_dpop_proof', 'DPoP proof header is required')
const requestedScopes = uniqueScopes(bodyString(ctx.body, 'scope'))
if (requestedScopes.length === 0 || requestedScopes.some((scope) => !isAuthorizationScope(scope))) {
throw oauthError('invalid_scope', 'Token exchange requires ZPan API scopes')
}
requireTokenType(ctx.body, 'subject_token_type')
requireTokenType(ctx.body, 'actor_token_type')
requireTokenType(ctx.body, 'requested_token_type')
const resource = bodyString(ctx.body, 'resource')
if (resource !== resourceAudience) throw oauthError('invalid_target', 'Unsupported token exchange resource')
const { client } = await provider.authenticateClient({ scopes: requestedScopes })
const subject = await provider.requireActiveAccessToken(bodyString(ctx.body, 'subject_token'), client.clientId)
const actor = await provider.requireActiveAccessToken(bodyString(ctx.body, 'actor_token'), client.clientId)
if (actor.zpan_actor_token !== true || typeof actor.sub !== 'string') {
throw oauthError('invalid_grant', 'Actor token is invalid')
}
const subjectScopes = uniqueScopes(typeof subject.scope === 'string' ? subject.scope : '')
if (requestedScopes.some((scope) => !subjectScopes.includes(scope))) {
throw oauthError('invalid_scope', 'Requested scope exceeds the connected account grant')
}
if (typeof subject.sub !== 'string') throw oauthError('invalid_grant', 'Subject token has no user')
const orgId = typeof subject.zpan_org_id === 'string' ? subject.zpan_org_id : undefined
if (!orgId) throw oauthError('invalid_grant', 'Subject token has no workspace')
const user = await ctx.context.internalAdapter.findUserById(subject.sub)
if (!user) throw oauthError('invalid_grant', 'Subject user no longer exists')
return provider.issueTokens({
client,
scopes: requestedScopes,
user,
referenceId: orgId,
resources: [resourceAudience],
accessTokenClaims: {
act: {
sub: actor.sub,
...(typeof actor.zpan_actor_issuer === 'string' ? { iss: actor.zpan_actor_issuer } : {}),
},
},
tokenResponse: { issued_token_type: OAUTH_ACCESS_TOKEN_TYPE },
})
},
},
}
}
async function verifyAgentAssertion(
ctx: Parameters<NonNullable<OAuthProviderExtension['grants']>[string]>[0]['ctx'],
opts: Parameters<NonNullable<OAuthProviderExtension['grants']>[string]>[0]['opts'],
client: SchemaClient<Scope[]>,
assertion: string,
) {
const jwks = client.jwks
? createLocalJWKSet(JSON.parse(client.jwks) as JSONWebKeySet)
: client.jwksUri
? 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'}`
let verified: Awaited<ReturnType<typeof jwtVerify>>
try {
verified = await jwtVerify(assertion, jwks, { audience: endpoint, maxTokenAge: '5m' })
} catch {
throw oauthError('invalid_grant', 'Agent assertion is invalid')
}
const issuer = requiredClaim(verified.payload.iss, 'assertion iss')
if (client.jwksUri) {
let issuerOrigin: string
try {
issuerOrigin = new URL(issuer).origin
} catch {
throw oauthError('invalid_grant', 'Agent assertion issuer must be an absolute URL')
}
if (issuerOrigin !== new URL(client.jwksUri).origin) {
throw oauthError('invalid_grant', 'Agent assertion issuer does not match the registered client')
}
}
await consumeClientAssertion(ctx, opts, {
namespace: `${JWT_BEARER_GRANT_TYPE}:${client.clientId}`,
payload: verified.payload,
expectedAudience: endpoint,
})
return verified.payload
}
function assertionUser(subject: string): User {
const now = new Date()
return {
id: subject,
name: subject,
email: `${encodeURIComponent(subject)}@agent.invalid`,
emailVerified: false,
image: null,
createdAt: now,
updatedAt: now,
}
}
function bodyString(body: unknown, field: string): string {
const value = body && typeof body === 'object' ? (body as Record<string, unknown>)[field] : undefined
if (typeof value !== 'string' || !value) throw oauthError('invalid_request', `${field} is required`)
return value
}
function requireTokenType(body: unknown, field: string) {
if (bodyString(body, field) !== OAUTH_ACCESS_TOKEN_TYPE) {
throw oauthError('invalid_request', `${field} must be ${OAUTH_ACCESS_TOKEN_TYPE}`)
}
}
function uniqueScopes(value: string): string[] {
return [...new Set(value.split(/\s+/).filter(Boolean))]
}
function requiredClaim(value: unknown, name: string): string {
if (typeof value !== 'string' || !value) throw oauthError('invalid_grant', `${name} is required`)
return value
}
function oauthError(error: string, errorDescription: string): APIError {
return new APIError('BAD_REQUEST', { error, error_description: errorDescription })
}
+104 -3
View File
@@ -1,5 +1,5 @@
import { relations, sql } from 'drizzle-orm'
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
export const user = sqliteTable(
'user',
@@ -54,7 +54,8 @@ export const account = sqliteTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
issuer: text('issuer').notNull().default(''),
providerAccountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
@@ -77,7 +78,10 @@ export const account = sqliteTable(
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index('account_userId_idx').on(table.userId)],
(table) => [
index('account_userId_idx').on(table.userId),
uniqueIndex('account_issuer_providerAccountId_unique').on(table.issuer, table.providerAccountId),
],
)
export const verification = sqliteTable(
@@ -98,6 +102,18 @@ export const verification = sqliteTable(
(table) => [index('verification_identifier_idx').on(table.identifier)],
)
export const jwks = sqliteTable('jwks', {
id: text('id').primaryKey(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
alg: text('alg'),
crv: text('crv'),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
})
export const organization = sqliteTable('organization', {
id: text('id').primaryKey(),
name: text('name').notNull(),
@@ -246,18 +262,71 @@ export const oauthClient = sqliteTable(
softwareStatement: text('software_statement'),
redirectUris: text('redirect_uris').notNull(), // JSON-serialized string[]
postLogoutRedirectUris: text('post_logout_redirect_uris'), // JSON-serialized string[]
backchannelLogoutUri: text('backchannel_logout_uri'),
backchannelLogoutSessionRequired: integer('backchannel_logout_session_required', { mode: 'boolean' }),
tokenEndpointAuthMethod: text('token_endpoint_auth_method'),
jwks: text('jwks'),
jwksUri: text('jwks_uri'),
grantTypes: text('grant_types'), // JSON-serialized string[]
responseTypes: text('response_types'), // JSON-serialized string[]
public: integer('public', { mode: 'boolean' }),
type: text('type'),
requirePKCE: integer('require_pkce', { mode: 'boolean' }),
dpopBoundAccessTokens: integer('dpop_bound_access_tokens', { mode: 'boolean' }).default(false),
referenceId: text('reference_id'),
metadata: text('metadata'),
},
(table) => [index('oauthClient_client_id_idx').on(table.clientId), index('oauthClient_user_id_idx').on(table.userId)],
)
export const oauthResource = sqliteTable(
'oauthResource',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull().unique(),
name: text('name').notNull(),
accessTokenTtl: integer('access_token_ttl'),
refreshTokenTtl: integer('refresh_token_ttl'),
signingAlgorithm: text('signing_algorithm'),
signingKeyId: text('signing_key_id'),
allowedScopes: text('allowed_scopes'),
customClaims: text('custom_claims', { mode: 'json' }),
dpopBoundAccessTokensRequired: integer('dpop_bound_access_tokens_required', { mode: 'boolean' }).default(false),
disabled: integer('disabled', { mode: 'boolean' }).default(false),
policyVersion: integer('policy_version').default(1),
metadata: text('metadata', { mode: 'json' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index('oauthResource_identifier_idx').on(table.identifier)],
)
export const oauthClientResource = sqliteTable(
'oauthClientResource',
{
id: text('id').primaryKey(),
clientId: text('client_id')
.notNull()
.references(() => oauthClient.clientId, { onDelete: 'cascade' }),
resourceId: text('resource_id')
.notNull()
.references(() => oauthResource.identifier, { onDelete: 'cascade' }),
metadata: text('metadata', { mode: 'json' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => [
index('oauthClientResource_client_id_idx').on(table.clientId),
index('oauthClientResource_resource_id_idx').on(table.resourceId),
],
)
export const oauthRefreshToken = sqliteTable(
'oauthRefreshToken',
{
@@ -271,12 +340,19 @@ export const oauthRefreshToken = sqliteTable(
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
referenceId: text('reference_id'),
authorizationCodeId: text('authorization_code_id'),
resources: text('resources'),
requestedUserInfoClaims: text('requested_user_info_claims'),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
revoked: integer('revoked', { mode: 'timestamp_ms' }),
rotatedAt: integer('rotated_at', { mode: 'timestamp_ms' }),
rotationReplayResponse: text('rotation_replay_response'),
rotationReplayExpiresAt: integer('rotation_replay_expires_at', { mode: 'timestamp_ms' }),
authTime: integer('auth_time', { mode: 'timestamp_ms' }),
confirmation: text('confirmation', { mode: 'json' }),
scopes: text('scopes').notNull(), // JSON-serialized string[]
},
(table) => [
@@ -298,11 +374,16 @@ export const oauthAccessToken = sqliteTable(
sessionId: text('session_id').references(() => session.id, { onDelete: 'set null' }),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
referenceId: text('reference_id'),
authorizationCodeId: text('authorization_code_id'),
resources: text('resources'),
requestedUserInfoClaims: text('requested_user_info_claims'),
refreshId: text('refresh_id').references(() => oauthRefreshToken.id, { onDelete: 'cascade' }),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
revoked: integer('revoked', { mode: 'timestamp_ms' }),
confirmation: text('confirmation', { mode: 'json' }),
scopes: text('scopes').notNull(), // JSON-serialized string[]
},
(table) => [
@@ -323,6 +404,8 @@ export const oauthConsent = sqliteTable(
.references(() => oauthClient.clientId, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
referenceId: text('reference_id'),
resources: text('resources'),
requestedUserInfoClaims: text('requested_user_info_claims'),
scopes: text('scopes').notNull(), // JSON-serialized string[]
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
@@ -339,6 +422,24 @@ export const oauthConsent = sqliteTable(
],
)
export const oauthClientAssertion = sqliteTable('oauthClientAssertion', {
id: text('id').primaryKey(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
})
export const oauthJwtRevocation = sqliteTable(
'oauthJwtRevocation',
{
id: text('id').primaryKey(),
clientId: text('client_id').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => [index('oauthJwtRevocation_expires_at_idx').on(table.expiresAt)],
)
export const downloaderBootstrapCredential = sqliteTable(
'downloader_bootstrap_credentials',
{
@@ -1,346 +0,0 @@
import { defaultKeyHasher } from '@better-auth/api-key'
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { authedHeaders, createTestApp } from '../test/setup.js'
type TestApp = Awaited<ReturnType<typeof createTestApp>>
function futureIso(days: number): string {
const date = new Date()
date.setDate(date.getDate() + days)
return date.toISOString()
}
async function getUserAndPersonalOrg(db: TestApp['db'], email = 'test@example.com') {
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`)
const orgs = await db.all<{ id: string }>(sql`
SELECT o.id
FROM organization o
INNER JOIN member m ON m.organization_id = o.id
WHERE m.user_id = ${users[0]?.id} AND o.metadata LIKE '%"type":"personal"%'
LIMIT 1
`)
if (!users[0] || !orgs[0]) throw new Error('expected user and personal org')
return { userId: users[0].id, orgId: orgs[0].id }
}
async function insertStorage(db: TestApp['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (
id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, enabled, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
'st-agent', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1',
'AKIAIOSFODNN7EXAMPLE', 'secret', '', '', 0, 0, 1, 'untested',
0, ${100 * 1024 ** 2}, 1, ${now}, ${now}
)
`)
}
async function insertFile(db: TestApp['db'], orgId: string, id: string) {
const now = Date.now()
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
VALUES (${id}, ${orgId}, ${`${id}-alias`}, ${`${id}.txt`}, 'text/plain', 100, 0, '', 'some/key.txt', 'st-agent', 'active', NULL, ${now}, ${now})
`)
}
async function insertLandingShare(
db: TestApp['db'],
input: { token: string; orgId: string; matterId: string; userId: string },
) {
const now = Date.now()
await db.run(sql`
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, status, private, created_at)
VALUES (${`${input.token}-id`}, ${input.token}, 'landing', ${input.matterId}, ${input.orgId}, ${input.userId}, 'active', 0, ${now})
`)
}
async function insertTeamOrg(db: TestApp['db'], orgId: string, userId: string, role = 'editor') {
const now = Date.now()
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata, created_at, updated_at)
VALUES (${orgId}, ${`Team ${orgId}`}, ${orgId}, '{"type":"team"}', ${now}, ${now})
`)
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role, created_at)
VALUES (${`${orgId}-member`}, ${orgId}, ${userId}, ${role}, ${now})
`)
await db.run(sql`
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
VALUES (${`${orgId}-quota`}, ${orgId}, 1000000, 0, 0, 0, '1970-01')
`)
}
async function createAgentKey(app: TestApp['app'], headers: Record<string, string>, orgId: string, scopes: string[]) {
const res = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'CI', scopes, expiresAt: futureIso(90) }),
})
if (res.status !== 201) throw new Error(`create failed: ${res.status} ${await res.text()}`)
return (await res.json()) as { key: string; item: { id: string; orgId: string; scopes: string[]; status: string } }
}
async function insertLegacyAgentKey(db: TestApp['db'], userId: string): Promise<string> {
const now = Date.now()
const key = 'zpan_agent_legacy_integration_key'
const hashedKey = await defaultKeyHasher(key)
await db.run(sql`
INSERT INTO apikey (
id, config_id, name, start, reference_id, prefix, key,
enabled, rate_limit_enabled, rate_limit_time_window, rate_limit_max, request_count,
expires_at, created_at, updated_at, permissions, metadata
)
VALUES (
'legacy-agent-key', 'agent', 'Legacy Agent key', 'zpan_age', ${userId}, 'zpan_agent_', ${hashedKey},
1, 1, 60000, 600, 0,
${now + 90 * 24 * 60 * 60 * 1000}, ${now}, ${now}, '{"objects":["read"]}', NULL
)
`)
return key
}
describe('Agent API keys', () => {
it('creates, lists, rotates, and revokes a personal workspace key [spec: agent-api-keys/lifecycle]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { orgId } = await getUserAndPersonalOrg(db)
const created = await createAgentKey(app, headers, orgId, ['objects:read'])
expect(created.key).toMatch(/^zpan_agent_/)
expect(created.item).toMatchObject({ orgId, scopes: ['objects:read'], status: 'active' })
const list = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, { headers })
expect(list.status).toBe(200)
const listed = (await list.json()) as { items: Array<{ id: string; key?: string }> }
expect(listed.items.map((item) => item.id)).toContain(created.item.id)
expect(listed.items[0]?.key).toBeUndefined()
const rotated = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${created.item.id}/rotations`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(rotated.status).toBe(201)
const rotatedBody = (await rotated.json()) as { key: string; item: { id: string } }
expect(rotatedBody.key).toMatch(/^zpan_agent_/)
expect(rotatedBody.item.id).not.toBe(created.item.id)
const revoke = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${rotatedBody.item.id}`, {
method: 'DELETE',
headers,
})
expect(revoke.status).toBe(204)
})
it('creates and uses a team workspace key for allowed file operations [spec: agent-api-keys/team-file-ops]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const { userId } = await getUserAndPersonalOrg(db)
await insertTeamOrg(db, 'agent-team', userId, 'owner')
await insertFile(db, 'agent-team', 'agent-readable')
const created = await createAgentKey(app, headers, 'agent-team', ['objects:read', 'objects:create'])
await db.run(sql`UPDATE member SET role = 'editor' WHERE organization_id = 'agent-team' AND user_id = ${userId}`)
const auth = { Authorization: `Bearer ${created.key}` }
const list = await app.request('/api/objects', { headers: auth })
expect(list.status).toBe(200)
const listBody = (await list.json()) as { items: Array<{ id: string }> }
expect(listBody.items.map((item) => item.id)).toContain('agent-readable')
const create = await app.request('/api/objects', {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'agent-folder', type: 'folder', dirtype: 1, parent: '' }),
})
expect(create.status).toBe(201)
})
it('allows team owners and admins to manage keys but denies editors [spec: agent-api-keys/management-role]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { userId } = await getUserAndPersonalOrg(db)
await insertStorage(db)
await insertTeamOrg(db, 'agent-editor-team', userId, 'editor')
await insertTeamOrg(db, 'agent-admin-team', userId, 'admin')
await insertFile(db, 'agent-admin-team', 'agent-admin-readable')
const editorList = await app.request('/api/workspaces/agent-editor-team/agent-api-keys', { headers })
expect(editorList.status).toBe(403)
const editorCreate = await app.request('/api/workspaces/agent-editor-team/agent-api-keys', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Denied', scopes: ['objects:read'], expiresAt: futureIso(90) }),
})
expect(editorCreate.status).toBe(403)
const adminCreated = await createAgentKey(app, headers, 'agent-admin-team', ['objects:read'])
expect(adminCreated.item.orgId).toBe('agent-admin-team')
const adminList = await app.request('/api/objects', {
headers: { Authorization: `Bearer ${adminCreated.key}` },
})
expect(adminList.status).toBe(200)
})
it('rejects disallowed scopes and raw Better Auth Agent key creation [spec: agent-api-keys/scope-boundary]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { orgId } = await getUserAndPersonalOrg(db)
const disallowed = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'bad', scopes: ['images:upload'], expiresAt: futureIso(90) }),
})
expect(disallowed.status).toBe(400)
const raw = await app.request('/api/auth/api-key/create', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ configId: 'agent', organizationId: orgId, permissions: { images: ['upload'] } }),
})
expect(raw.status).toBe(400)
})
it('denies missing scope, wrong workspace, revoked key, expired key, and banned owner [spec: agent-api-keys/denials]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { orgId, userId } = await getUserAndPersonalOrg(db)
await insertStorage(db)
const created = await createAgentKey(app, headers, orgId, ['objects:create'])
const auth = { Authorization: `Bearer ${created.key}` }
const missingScope = await app.request('/api/objects', { headers: auth })
expect(missingScope.status).toBe(403)
const wrongWorkspace = await app.request('/api/objects?orgId=agent-other-workspace', { headers: auth })
expect(wrongWorkspace.status).toBe(403)
await app.request(`/api/workspaces/${orgId}/agent-api-keys/${created.item.id}`, { method: 'DELETE', headers })
const revoked = await app.request('/api/objects', { headers: auth })
expect(revoked.status).toBe(401)
const expired = await createAgentKey(app, headers, orgId, ['objects:read'])
await db.run(sql`UPDATE apikey SET expires_at = ${Date.now() - 1000} WHERE id = ${expired.item.id}`)
const expiredRes = await app.request('/api/objects', { headers: { Authorization: `Bearer ${expired.key}` } })
expect(expiredRes.status).toBe(401)
const banned = await createAgentKey(app, headers, orgId, ['objects:read'])
await db.run(sql`UPDATE user SET banned = 1 WHERE id = ${userId}`)
const bannedRes = await app.request('/api/objects', { headers: { Authorization: `Bearer ${banned.key}` } })
expect(bannedRes.status).toBe(401)
})
it('treats expired and revoked keys as terminal for rotation [spec: agent-api-keys/terminal-rotation]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { orgId } = await getUserAndPersonalOrg(db)
const expired = await createAgentKey(app, headers, orgId, ['objects:read'])
await db.run(sql`UPDATE apikey SET expires_at = ${Date.now() - 1000} WHERE id = ${expired.item.id}`)
const expiredRotation = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${expired.item.id}/rotations`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresAt: futureIso(90) }),
})
expect(expiredRotation.status).toBe(409)
await expect(expiredRotation.json()).resolves.toMatchObject({
error: { details: [{ reason: 'AGENT_API_KEY_NOT_ACTIVE' }] },
})
const revoked = await createAgentKey(app, headers, orgId, ['objects:read'])
await app.request(`/api/workspaces/${orgId}/agent-api-keys/${revoked.item.id}`, {
method: 'DELETE',
headers,
})
const revokedRotation = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${revoked.item.id}/rotations`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(revokedRotation.status).toBe(409)
await expect(revokedRotation.json()).resolves.toMatchObject({
error: { details: [{ reason: 'AGENT_API_KEY_NOT_ACTIVE' }] },
})
})
it('rechecks team role before management and file operations [spec: agent-api-keys/role-reduction]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { userId } = await getUserAndPersonalOrg(db)
await insertStorage(db)
await insertTeamOrg(db, 'agent-role-team', userId, 'owner')
await insertFile(db, 'agent-role-team', 'agent-role-share-file')
await insertLandingShare(db, {
token: 'agent-role-share',
orgId: 'agent-role-team',
matterId: 'agent-role-share-file',
userId,
})
const created = await createAgentKey(app, headers, 'agent-role-team', [
'objects:create',
'shares:create',
'shares:delete',
])
await db.run(
sql`UPDATE member SET role = 'viewer' WHERE organization_id = 'agent-role-team' AND user_id = ${userId}`,
)
const auth = { Authorization: `Bearer ${created.key}`, 'Content-Type': 'application/json' }
const management = await app.request('/api/workspaces/agent-role-team/agent-api-keys', { headers })
expect(management.status).toBe(403)
const create = await app.request('/api/objects', {
method: 'POST',
headers: auth,
body: JSON.stringify({ name: 'blocked', type: 'folder', dirtype: 1, parent: '' }),
})
expect(create.status).toBe(403)
const privacy = await app.request('/api/shares/agent-role-share/privacy', {
method: 'PUT',
headers: auth,
body: JSON.stringify({ private: true }),
})
expect(privacy.status).toBe(403)
const revoke = await app.request('/api/shares/agent-role-share/status', {
method: 'PUT',
headers: auth,
body: JSON.stringify({ status: 'revoked' }),
})
expect(revoke.status).toBe(403)
})
it('denies an old team workspace key after the owner membership is removed [spec: agent-api-keys/denials]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { userId } = await getUserAndPersonalOrg(db)
await insertStorage(db)
await insertTeamOrg(db, 'agent-removed-team', userId, 'owner')
await insertFile(db, 'agent-removed-team', 'agent-removed-readable')
const created = await createAgentKey(app, headers, 'agent-removed-team', ['objects:read'])
await db.run(sql`DELETE FROM member WHERE organization_id = 'agent-removed-team' AND user_id = ${userId}`)
const denied = await app.request('/api/objects?orgId=agent-removed-team', {
headers: { Authorization: `Bearer ${created.key}` },
})
expect(denied.status).toBe(403)
})
it('denies a legacy Better Auth Agent key without scoped metadata [spec: agent-api-keys/denials]', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
const { userId } = await getUserAndPersonalOrg(db)
const key = await insertLegacyAgentKey(db, userId)
const denied = await app.request('/api/objects', { headers: { Authorization: `Bearer ${key}` } })
expect(denied.status).toBe(401)
})
})
-130
View File
@@ -1,130 +0,0 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
agentApiKeyCreatedSchema,
agentApiKeyCreateSchema,
agentApiKeyListSchema,
agentApiKeyRotateSchema,
} from '@shared/schemas'
import type { Env } from '../middleware/platform'
import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '../usecases/agent-api-keys'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
const workspaceParamsSchema = z.object({ orgId: z.string().min(1) })
const keyParamsSchema = workspaceParamsSchema.extend({ keyId: z.string().min(1) })
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
const listRoute = authRoute(
{ scopes: [AuthorizationScope.AGENT_API_KEYS_READ] },
{
operationId: 'listWorkspaceAgentApiKeys',
summary: 'List Agent API keys for a workspace',
tags: ['Agent Access'],
method: 'get',
path: '/{orgId}/agent-api-keys',
request: { params: workspaceParamsSchema, query: listQuerySchema },
responses: {
200: jsonContent(agentApiKeyListSchema, 'Agent API keys'),
403: errorResponse('Forbidden'),
},
},
)
const createRoute = authRoute(
{ scopes: [AuthorizationScope.AGENT_API_KEYS_CREATE] },
{
operationId: 'createWorkspaceAgentApiKey',
summary: 'Create an Agent API key for a workspace',
tags: ['Agent Access'],
method: 'post',
path: '/{orgId}/agent-api-keys',
request: { params: workspaceParamsSchema, ...jsonBody(agentApiKeyCreateSchema) },
responses: {
201: jsonContent(agentApiKeyCreatedSchema, 'Created Agent API key'),
400: errorResponse('Bad request'),
403: errorResponse('Forbidden'),
},
},
)
const rotateRoute = authRoute(
{ scopes: [AuthorizationScope.AGENT_API_KEYS_UPDATE] },
{
operationId: 'rotateWorkspaceAgentApiKey',
summary: 'Rotate an Agent API key for a workspace',
tags: ['Agent Access'],
method: 'post',
path: '/{orgId}/agent-api-keys/{keyId}/rotations',
request: { params: keyParamsSchema, ...jsonBody(agentApiKeyRotateSchema) },
responses: {
201: jsonContent(agentApiKeyCreatedSchema, 'Rotated Agent API key'),
400: errorResponse('Bad request'),
409: errorResponse('Agent API key is not active'),
403: errorResponse('Forbidden'),
404: errorResponse('Agent API key not found'),
},
},
)
const revokeRoute = authRoute(
{ scopes: [AuthorizationScope.AGENT_API_KEYS_DELETE] },
{
operationId: 'revokeWorkspaceAgentApiKey',
summary: 'Revoke an Agent API key for a workspace',
tags: ['Agent Access'],
method: 'delete',
path: '/{orgId}/agent-api-keys/{keyId}',
request: { params: keyParamsSchema },
responses: {
204: { description: 'Revoked' },
403: errorResponse('Forbidden'),
404: errorResponse('Agent API key not found'),
},
},
)
const agentApiKeys = new OpenAPIHono<Env>()
.openapi(listRoute, async (c) => {
const { orgId } = c.req.valid('param')
const { page, pageSize } = c.req.valid('query')
const result = await listAgentApiKeys(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
orgId,
page,
pageSize,
})
return c.json(result, 200)
})
.openapi(createRoute, async (c) => {
const { orgId } = c.req.valid('param')
const result = await createAgentApiKey(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
orgId,
body: c.req.valid('json'),
})
return c.json(result, 201)
})
.openapi(rotateRoute, async (c) => {
const { orgId, keyId } = c.req.valid('param')
const result = await rotateAgentApiKey(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
orgId,
keyId,
body: c.req.valid('json'),
})
return c.json(result, 201)
})
.openapi(revokeRoute, async (c) => {
const { orgId, keyId } = c.req.valid('param')
await revokeAgentApiKey(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
orgId,
keyId,
})
return c.body(null, 204)
})
export default agentApiKeys
@@ -1,16 +1,14 @@
import { createHash } from 'node:crypto'
import {
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_CLIENT_ID,
AGENT_OAUTH_CLIENT_NAME,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
} from '@shared/agent-oauth'
import { AGENT_OAUTH_ACCESS_TOKEN_SECONDS, AGENT_OAUTH_REFRESH_TOKEN_SECONDS } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { eq, sql } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
const CLIENT_ID = 'dynamic-client'
const CLIENT_NAME = 'FlareAuth'
const REDIRECT_URI = 'https://flareauth.example/callback'
type TestContext = Awaited<ReturnType<typeof createTestApp>>
async function getUserAndPersonalOrg(db: TestContext['db'], email: string) {
@@ -26,16 +24,31 @@ async function getUserAndPersonalOrg(db: TestContext['db'], email: string) {
return rows[0]
}
async function insertTeamOrg(db: TestContext['db'], orgId: string, userId: string) {
const now = Date.now()
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata, created_at, updated_at)
VALUES (${orgId}, ${`Team ${orgId}`}, ${orgId}, '{"type":"team"}', ${now}, ${now})
`)
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role, created_at)
VALUES (${`${orgId}-member`}, ${orgId}, ${userId}, 'owner', ${now})
`)
async function insertClient(db: TestContext['db']) {
await db.insert(authSchema.oauthClient).values({
id: CLIENT_ID,
clientId: CLIENT_ID,
clientSecret: null,
disabled: false,
skipConsent: false,
enableEndSession: false,
subjectType: 'public',
scopes: JSON.stringify([
'openid',
'offline_access',
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.QUOTA_READ,
]),
name: CLIENT_NAME,
uri: 'https://flareauth.example',
redirectUris: JSON.stringify([REDIRECT_URI]),
tokenEndpointAuthMethod: 'none',
grantTypes: JSON.stringify(['authorization_code', 'refresh_token']),
responseTypes: JSON.stringify(['code']),
public: true,
type: 'web',
requirePKCE: true,
})
}
async function insertGrant(
@@ -45,7 +58,7 @@ async function insertGrant(
const now = new Date('2026-07-29T12:00:00.000Z')
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
clientId: CLIENT_ID,
userId: input.userId,
referenceId: input.orgId,
scopes: JSON.stringify(input.scopes),
@@ -55,7 +68,7 @@ async function insertGrant(
await db.insert(authSchema.oauthRefreshToken).values({
id: 'refresh-1',
token: 'hashed-refresh',
clientId: AGENT_OAUTH_CLIENT_ID,
clientId: CLIENT_ID,
userId: input.userId,
referenceId: input.orgId,
expiresAt: new Date(Date.now() + 60_000),
@@ -64,8 +77,8 @@ async function insertGrant(
})
await db.insert(authSchema.oauthAccessToken).values({
id: 'access-1',
token: hashStoredToken('live-agent-token'),
clientId: AGENT_OAUTH_CLIENT_ID,
token: 'hashed-access',
clientId: CLIENT_ID,
userId: input.userId,
referenceId: input.orgId,
refreshId: 'refresh-1',
@@ -75,29 +88,35 @@ async function insertGrant(
})
}
function oauthQuery() {
return new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
}).toString()
}
describe('Agent OAuth grants API integration', () => {
it('returns server-owned Agent OAuth consent context for the active workspace', async () => {
it('returns consent context for a dynamically registered application', async () => {
const { app, db } = await createTestApp()
await insertClient(db)
const headers = await authedHeaders(app, 'agent-consent@example.com')
const { orgId } = await getUserAndPersonalOrg(db, 'agent-consent@example.com')
const oauthQuery = new URLSearchParams({
client_id: AGENT_OAUTH_CLIENT_ID,
redirect_uri: 'http://127.0.0.1:8484/callback',
response_type: 'code',
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
}).toString()
const res = await app.request(`/api/agent-oauth-consent?oauthQuery=${encodeURIComponent(oauthQuery)}`, { headers })
const res = await app.request(`/api/agent-oauth-consent?oauthQuery=${encodeURIComponent(oauthQuery())}`, {
headers,
})
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({
clientId: AGENT_OAUTH_CLIENT_ID,
clientName: AGENT_OAUTH_CLIENT_NAME,
clientId: CLIENT_ID,
clientName: CLIENT_NAME,
instanceOrigin: 'http://localhost',
workspace: { id: orgId, name: expect.any(String) },
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
redirectUri: REDIRECT_URI,
grantLifetime: {
accessTokenSeconds: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
refreshTokenSeconds: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
@@ -105,61 +124,24 @@ describe('Agent OAuth grants API integration', () => {
})
})
it('revalidates OAuth consent submission through the Agent Access API', async () => {
const { app } = await createTestApp()
it('revalidates malformed OAuth consent submissions', async () => {
const { app, db } = await createTestApp()
await insertClient(db)
const headers = await authedHeaders(app, 'agent-submit@example.com')
const res = await app.request('/api/agent-oauth-consent', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ accept: true, oauthQuery: 'client_id=zpan-agent&response_type=token' }),
body: JSON.stringify({ accept: true, oauthQuery: `client_id=${CLIENT_ID}&response_type=token` }),
})
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({
error: {
message: 'Invalid Agent OAuth request',
},
})
await expect(res.json()).resolves.toMatchObject({ error: { message: 'Invalid Agent OAuth request' } })
})
it('submits full OAuth consent through the Agent Access API', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app, 'agent-submit-success@example.com')
const oauthParams = new URLSearchParams({
client_id: AGENT_OAUTH_CLIENT_ID,
redirect_uri: 'http://127.0.0.1:8484/callback',
response_type: 'code',
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
state: 'agent-submit-success',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
})
const authorize = await app.request(`/api/auth/oauth2/authorize?${oauthParams}`, {
headers: { ...headers, Origin: 'http://localhost' },
})
const consentLocation = authorize.headers.get('location')
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
const consent = await app.request('/api/agent-oauth-consent', {
method: 'POST',
headers: { ...headers, Origin: 'http://localhost', 'Content-Type': 'application/json' },
body: JSON.stringify({
accept: true,
oauthQuery: consentLocation?.slice(consentLocation.indexOf('?') + 1),
}),
})
const consentBody = await consent.text()
expect(consent.status, consentBody).toBe(200)
expect(JSON.parse(consentBody)).toMatchObject({
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8484\/callback\?code=/),
})
})
it('lists and revokes the current user grant family', async () => {
it('lists and revokes the current user dynamic-client grant family', async () => {
const { app, db } = await createTestApp()
await insertClient(db)
const headers = await authedHeaders(app, 'agent-grants@example.com')
const { userId, orgId } = await getUserAndPersonalOrg(db, 'agent-grants@example.com')
await insertGrant(db, { userId, orgId, scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ] })
@@ -170,8 +152,8 @@ describe('Agent OAuth grants API integration', () => {
items: [
{
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
clientName: 'ZPan Agent',
clientId: CLIENT_ID,
clientName: CLIENT_NAME,
userId,
orgId,
workspaceName: expect.any(String),
@@ -191,68 +173,13 @@ describe('Agent OAuth grants API integration', () => {
expect(refresh.revoked).not.toBeNull()
})
it('enforces live grant membership and a bound workspace for Agent OAuth bearer access', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app, 'agent-scope@example.com')
const { userId, orgId } = await getUserAndPersonalOrg(db, 'agent-scope@example.com')
await insertTeamOrg(db, 'other-workspace', userId)
await insertGrant(db, { userId, orgId, scopes: [AuthorizationScope.OBJECTS_READ] })
const list = await app.request('/api/agent-oauth-grants', { headers })
expect(list.status).toBe(200)
await expect(list.json()).resolves.toMatchObject({ items: [{ id: 'grant-1', lastUsedAt: null }] })
const bearer = { Authorization: 'Bearer live-agent-token' }
const allowed = await app.request('/api/objects', { headers: bearer })
expect(allowed.status).toBe(200)
const [usedGrant] = await db
.select({ lastUsedAt: authSchema.oauthConsent.lastUsedAt })
.from(authSchema.oauthConsent)
.where(eq(authSchema.oauthConsent.id, 'grant-1'))
expect(usedGrant.lastUsedAt).toBeInstanceOf(Date)
const wrongWorkspace = await app.request('/api/objects?orgId=other-workspace', { headers: bearer })
expect(wrongWorkspace.status).toBe(403)
const revoke = await app.request('/api/agent-oauth-grants/grant-1', { method: 'DELETE', headers })
expect(revoke.status).toBe(204)
const revoked = await app.request('/api/objects', { headers: bearer })
expect(revoked.status).toBe(401)
})
it('blocks generic Better Auth OAuth consent mutation endpoints', async () => {
const { app } = await createTestApp()
for (const path of ['/api/auth/oauth2/update-consent', '/api/auth/oauth2/delete-consent']) {
const res = await app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: AGENT_OAUTH_CLIENT_ID }),
})
expect(res.status).toBe(403)
await expect(res.json()).resolves.toMatchObject({
error_description: 'Manage Agent OAuth grants from the Agent Access API',
})
}
})
it('returns 404 when revoking a missing Agent OAuth grant', async () => {
it('returns 404 when revoking a missing grant', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app, 'agent-missing-grant@example.com')
const revoke = await app.request('/api/agent-oauth-grants/missing-grant', { method: 'DELETE', headers })
expect(revoke.status).toBe(404)
await expect(revoke.json()).resolves.toMatchObject({
error: {
message: 'Agent OAuth grant not found',
},
})
await expect(revoke.json()).resolves.toMatchObject({ error: { message: 'Agent OAuth grant not found' } })
})
})
function hashStoredToken(token: string): string {
return createHash('sha256').update(token).digest('base64url')
}
+2
View File
@@ -82,6 +82,7 @@ export const agentOAuthGrants = new OpenAPIHono<Env>()
.openapi(consentContextRoute, async (c) => {
const { oauthQuery } = c.req.valid('query')
const context = await getAgentOAuthConsentContext(c.get('deps'), {
db: c.get('platform').db,
userId: c.get('userId')!,
orgId: c.get('orgId'),
requestUrl: c.req.url,
@@ -92,6 +93,7 @@ export const agentOAuthGrants = new OpenAPIHono<Env>()
.openapi(consentSubmitRoute, async (c) => {
const { accept, oauthQuery } = c.req.valid('json')
await getAgentOAuthConsentContext(c.get('deps'), {
db: c.get('platform').db,
userId: c.get('userId')!,
orgId: c.get('orgId'),
requestUrl: c.req.url,
+184
View File
@@ -0,0 +1,184 @@
export const ARAZZO_DOCUMENT_PATH = '/api/workflows.arazzo.json'
export const ARAZZO_MEDIA_TYPE = 'application/vnd.oai.workflows+json; version=1.1.0'
export function createArazzoDocument(origin: string) {
return {
arazzo: '1.1.0',
$self: `${origin}${ARAZZO_DOCUMENT_PATH}`,
info: {
title: 'ZPan API workflows',
summary: 'Machine-readable file workflows for the ZPan API',
description:
'These workflows compose the OpenAPI operations around direct-to-storage uploads. Presigned storage requests are executed from the runtime upload descriptor returned by prepareDirectFileUpload.',
version: '1.0.0',
},
sourceDescriptions: [
{
name: 'zpan',
url: './openapi.json',
type: 'openapi',
},
],
workflows: [
{
workflowId: 'prepareDirectFileUpload',
summary: 'Prepare a direct file upload',
description:
'Creates a file draft and returns the runtime upload descriptor. PUT every local file slice identified by upload.parts[].offset and upload.parts[].length to upload.parts[].url with upload.parts[].headers. Capture each response ETag, then invoke completeDirectFileUpload. If a presigned URL expires, invoke refreshDirectFileUploadParts. File bytes are sent directly to storage, not to ZPan.',
inputs: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
contentType: { type: 'string', minLength: 1 },
size: { type: 'integer', minimum: 0 },
parent: { type: 'string', default: '' },
onConflict: {
type: 'string',
enum: ['fail', 'rename', 'replace'],
default: 'fail',
},
},
required: ['name', 'contentType', 'size', 'parent', 'onConflict'],
},
steps: [
{
stepId: 'createUploadDraft',
operationId: 'createObject',
requestBody: {
contentType: 'application/json',
payload: {
name: '$inputs.name',
type: '$inputs.contentType',
size: '$inputs.size',
parent: '$inputs.parent',
onConflict: '$inputs.onConflict',
},
},
successCriteria: [{ condition: '$statusCode == 201' }],
outputs: {
objectId: '$response.body#/id',
sessionId: '$response.body#/upload/sessionId',
upload: '$response.body#/upload',
},
},
],
outputs: {
objectId: '$steps.createUploadDraft.outputs.objectId',
sessionId: '$steps.createUploadDraft.outputs.sessionId',
upload: '$steps.createUploadDraft.outputs.upload',
},
},
{
workflowId: 'refreshDirectFileUploadParts',
summary: 'Refresh expired direct-upload URLs',
description:
'Requests replacement presigned URLs for selected multipart upload parts. Continue using the returned offset, length, headers, and URL for each part.',
inputs: {
type: 'object',
properties: {
objectId: { type: 'string', minLength: 1 },
sessionId: { type: 'string', minLength: 1 },
partNumbers: {
type: 'array',
minItems: 1,
items: { type: 'integer', minimum: 1 },
},
},
required: ['objectId', 'sessionId', 'partNumbers'],
},
steps: [
{
stepId: 'refreshUploadParts',
operationId: 'presignObjectUploadParts',
parameters: [
{ name: 'id', in: 'path', value: '$inputs.objectId' },
{ name: 'uploadSessionId', in: 'path', value: '$inputs.sessionId' },
],
requestBody: {
contentType: 'application/json',
payload: { partNumbers: '$inputs.partNumbers' },
},
successCriteria: [{ condition: '$statusCode == 200' }],
outputs: {
uploadParts: '$response.body',
},
},
],
outputs: {
uploadParts: '$steps.refreshUploadParts.outputs.uploadParts',
},
},
{
workflowId: 'completeDirectFileUpload',
summary: 'Complete a direct file upload',
description:
'Finalizes a prepared upload after every part has been PUT to storage. Supply one partNumber and captured ETag for every advertised part.',
inputs: {
type: 'object',
properties: {
objectId: { type: 'string', minLength: 1 },
sessionId: { type: 'string', minLength: 1 },
parts: {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
partNumber: { type: 'integer', minimum: 1 },
etag: { type: 'string', minLength: 1 },
},
required: ['partNumber', 'etag'],
},
},
},
required: ['objectId', 'sessionId', 'parts'],
},
steps: [
{
stepId: 'completeUpload',
operationId: 'completeObjectUpload',
parameters: [
{ name: 'id', in: 'path', value: '$inputs.objectId' },
{ name: 'uploadSessionId', in: 'path', value: '$inputs.sessionId' },
],
requestBody: {
contentType: 'application/json',
payload: { parts: '$inputs.parts' },
},
successCriteria: [{ condition: '$statusCode == 200' }],
outputs: {
object: '$response.body',
},
},
],
outputs: {
object: '$steps.completeUpload.outputs.object',
},
},
{
workflowId: 'abortDirectFileUpload',
summary: 'Abort an unfinished direct file upload',
description: 'Discards an unfinished upload session and its draft object.',
inputs: {
type: 'object',
properties: {
objectId: { type: 'string', minLength: 1 },
sessionId: { type: 'string', minLength: 1 },
},
required: ['objectId', 'sessionId'],
},
steps: [
{
stepId: 'abortUpload',
operationId: 'abortObjectUpload',
parameters: [
{ name: 'id', in: 'path', value: '$inputs.objectId' },
{ name: 'uploadSessionId', in: 'path', value: '$inputs.sessionId' },
],
successCriteria: [{ condition: '$statusCode == 204' }],
},
],
},
],
} as const
}
+17 -3
View File
@@ -55,9 +55,23 @@ describe('[CF] Auth API', () => {
.getSetCookie()
.map((value) => value.split(';', 1)[0])
.join('; ')
const registration = await app.request('/api/auth/oauth2/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'CF Consent Test Client',
redirect_uris: ['https://broker.example.com/callback'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
scope: 'openid offline_access objects:read quota:read',
}),
})
const registered = (await registration.json()) as { client_id: string }
expect(registration.status).toBe(201)
const params = new URLSearchParams({
client_id: 'zpan-agent',
redirect_uri: 'http://127.0.0.1:8484/callback',
client_id: registered.client_id,
redirect_uri: 'https://broker.example.com/callback',
response_type: 'code',
scope: 'openid offline_access objects:read quota:read',
state: 'cf-agent-oauth',
@@ -83,7 +97,7 @@ describe('[CF] Auth API', () => {
expect(consent.status, consentBody).toBe(200)
expect(JSON.parse(consentBody)).toMatchObject({
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8484\/callback\?code=/),
url: expect.stringMatching(/^https:\/\/broker\.example\.com\/callback\?code=/),
})
})
@@ -207,7 +207,7 @@ describe('Download tasks API integration', () => {
const wrongClient = await app.request('/api/auth/device/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'zpan-agent', scope: 'downloader:register' }),
body: JSON.stringify({ client_id: 'unrelated-client', scope: 'downloader:register' }),
})
expect(wrongClient.status).toBe(400)
})
+47
View File
@@ -0,0 +1,47 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AGENT_OAUTH_RESOURCE_SCOPES, AGENT_OAUTH_SCOPE_DESCRIPTIONS } from '@shared/agent-oauth'
import type { Env } from '../middleware/platform'
import { authRoute, jsonContent } from './openapi'
const scopeSchema = z.object({
value: z.string(),
description: z.string(),
})
const route = authRoute(
{ public: true },
{
operationId: 'listOAuthResourceScopes',
summary: 'List OAuth resource scopes',
description:
'Public scope catalog for external authorization controllers. Runtime API operations remain protected by their x-zpan-auth declarations.',
tags: ['OAuth'],
method: 'get',
path: '/',
responses: {
200: jsonContent(z.object({ scopes: z.array(scopeSchema) }), 'OAuth resource scope catalog'),
},
},
)
// FlareAuth derives requestable business scopes from standard OAuth operation
// security. The empty alternative truthfully documents that this catalog
// endpoint itself is public. Protected business operations remain unbound so a
// delegated credential hook can sign them before Restish's built-in auth runs.
const scopeCatalogSecurity: Record<string, string[]>[] = [{ agentOAuth2: [...AGENT_OAUTH_RESOURCE_SCOPES] }, {}]
const scopeCatalogRoute = Object.assign(route, {
security: scopeCatalogSecurity,
'x-mcp-ignore': true,
})
export const oauthResourceScopes = new OpenAPIHono<Env>().openapi(scopeCatalogRoute, (c) =>
c.json(
{
scopes: AGENT_OAUTH_RESOURCE_SCOPES.map((value) => ({
value,
description: AGENT_OAUTH_SCOPE_DESCRIPTIONS[value],
})),
},
200,
),
)
+6
View File
@@ -201,6 +201,8 @@ const createObjectRoute = authRoute(
{
operationId: 'createObject',
summary: 'Create object',
description:
'For a file, creates a draft and returns a self-contained direct-upload workflow. PUT each local file slice identified by upload.parts[].offset and upload.parts[].length to its presigned URL with the supplied headers, record each response ETag, then call upload.workflow.complete.operationId with every {partNumber, etag}. File bytes go directly to storage and never pass through ZPan. Use the advertised re-presign action for expired URLs and abort action to discard an unfinished draft.',
tags: ['Objects'],
method: 'post',
path: '/',
@@ -220,6 +222,8 @@ const presignPartsRoute = authRoute(
{
operationId: 'presignObjectUploadParts',
summary: 'Re-presign upload parts',
description:
'Returns replacement presigned PUT descriptors for the requested part numbers. Each descriptor includes the exact local file offset and length. Upload those slices, capture each response ETag, and finish through completeObjectUpload.',
tags: ['Objects'],
method: 'post',
path: '/{id}/uploads/{uploadSessionId}/parts',
@@ -239,6 +243,8 @@ const completionsRoute = authRoute(
{
operationId: 'completeObjectUpload',
summary: 'Complete upload',
description:
'Finalizes a direct upload after every advertised part has been PUT to storage. Send one {partNumber, etag} entry per part, using the ETag response header returned by storage.',
tags: ['Objects'],
method: 'post',
path: '/{id}/uploads/{uploadSessionId}/completions',
+9 -10
View File
@@ -1,5 +1,5 @@
import { createRoute, type RouteConfig, type z } from '@hono/zod-openapi'
import { AGENT_GRANTABLE_API_KEY_SCOPES } from '@shared/api-key-templates'
import { AGENT_OAUTH_SCOPES } from '@shared/agent-oauth'
import { errorResponseSchema } from '@shared/schemas'
import { authorize, type RouteAuthorizationDeclaration, type ScopedAuthorizationPolicy } from '../middleware/authz'
@@ -24,7 +24,7 @@ export const jsonBody = <T extends z.ZodType>(schema: T) => ({
// `jsonError`; this just documents the response shape in the OpenAPI document.
export const errorResponse = (description: string) => jsonContent(errorResponseSchema, description)
const AGENT_GRANTABLE_SCOPE_SET = new Set<string>(AGENT_GRANTABLE_API_KEY_SCOPES)
const AGENT_OAUTH_SCOPE_SET = new Set<string>(AGENT_OAUTH_SCOPES)
export function authRoute<P extends string, T extends Omit<RouteConfig, 'path'> & { path: P }>(
auth: RouteAuthorizationDeclaration,
@@ -34,7 +34,7 @@ export function authRoute<P extends string, T extends Omit<RouteConfig, 'path'>
return createRoute({
...config,
middleware,
security: openApiSecurity(auth),
...openApiSecurity(auth),
'x-zpan-auth': openApiAuthMetadata(auth),
...openApiCliMetadata(auth),
} as T) as T & { getRoutingPath(): string }
@@ -62,9 +62,10 @@ function hasValidAuthContract(operation: object): boolean {
return auth.public ? auth.scopes.length === 0 : auth.scopes.length > 0
}
function openApiSecurity(auth: RouteAuthorizationDeclaration): Record<string, string[]>[] {
if ('public' in auth) return []
return openApiPolicySecurity(auth)
function openApiSecurity(auth: RouteAuthorizationDeclaration): { security?: Record<string, string[]>[] } {
if ('public' in auth) return { security: [] }
if (isAgentCallablePolicy(auth)) return {}
return { security: openApiPolicySecurity(auth) }
}
function openApiCliMetadata(auth: RouteAuthorizationDeclaration): Record<string, boolean> {
@@ -81,13 +82,11 @@ function openApiAuthMetadata(auth: RouteAuthorizationDeclaration): Record<string
}
function openApiPolicySecurity(policy: ScopedAuthorizationPolicy): Record<string, string[]>[] {
return policy.scopes.every((scope) => AGENT_GRANTABLE_SCOPE_SET.has(scope))
? [{ agentOAuth2: [...policy.scopes] }, { agentApiKey: [...policy.scopes] }, { cookieAuth: [] }]
: [{ bearerAuth: [...policy.scopes] }, { cookieAuth: [] }]
return [{ bearerAuth: [...policy.scopes] }, { cookieAuth: [] }]
}
function isAgentCallablePolicy(policy: ScopedAuthorizationPolicy): boolean {
return policy.scopes.every((scope) => AGENT_GRANTABLE_SCOPE_SET.has(scope))
return policy.scopes.every((scope) => AGENT_OAUTH_SCOPE_SET.has(scope))
}
function openApiPolicyMetadata(policy: ScopedAuthorizationPolicy): Record<string, unknown> {
+27 -3
View File
@@ -1,7 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import type { Env } from '../../middleware/platform'
import { deleteAuthProvider, listAuthProviders, upsertAuthProvider } from '../../usecases/site/auth-provider'
import { deleteAuthProvider, listAuthProviderSettings, upsertAuthProvider } from '../../usecases/site/auth-provider'
import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
// Full management shape. Public consumers receive the minimal provider projection
@@ -28,6 +28,18 @@ const authProviderListSchema = z
page: z.number().int(),
pageSize: z.number().int(),
callbackBaseUri: z.string(),
registeredApplications: z.array(
z.object({
clientId: z.string(),
name: z.string(),
uri: z.string().nullable(),
redirectUris: z.array(z.string()),
grantTypes: z.array(z.string()),
scopes: z.array(z.string()),
disabled: z.boolean(),
createdAt: z.string(),
}),
),
})
.openapi('AuthProviderList')
@@ -97,8 +109,20 @@ function resolveAuthBaseUri(c: { get(key: 'platform'): Env['Variables']['platfor
export const authProviders = new OpenAPIHono<Env>()
.openapi(listRoute, async (c) => {
const authOrigin = resolveAuthBaseUri(c)
const { items } = await listAuthProviders(c.get('deps'), { authOrigin })
return c.json({ items, total: items.length, page: 1, pageSize: items.length, callbackBaseUri: authOrigin }, 200)
const { items, registeredApplications } = await listAuthProviderSettings(c.get('deps'), c.get('platform').db, {
authOrigin,
})
return c.json(
{
items,
total: items.length,
page: 1,
pageSize: items.length,
callbackBaseUri: authOrigin,
registeredApplications,
},
200,
)
})
.openapi(upsertRoute, async (c) => {
const authOrigin = resolveAuthBaseUri(c)
+1 -1
View File
@@ -8,7 +8,7 @@ describe('auditActor', () => {
kind: 'agent-oauth',
userId: 'user-1',
grantId: 'grant-1',
clientId: 'zpan-agent',
clientId: 'dynamic-client',
orgId: 'org-1',
scopes: [],
authMethod: 'bearer',
+67 -27
View File
@@ -1,4 +1,7 @@
import { oauthProviderResourceClient } from '@better-auth/oauth-provider/resource-client'
import { AuthorizationScope, isAuthorizationScope, permissionScopes } from '@shared/authorization'
import { APIError } from 'better-auth'
import { createDpopReplayStore } from 'better-auth/oauth2'
import { createMiddleware } from 'hono/factory'
import {
isDownloaderBootstrapRegistrationRequest,
@@ -6,7 +9,7 @@ import {
LEGACY_DOWNLOADER_CLIENT_ID,
LEGACY_DOWNLOADER_REGISTER_SCOPE,
} from '../domain/legacy-downloader-bootstrap'
import { ApiKeyRateLimitError, rateLimited, unauthorized } from '../usecases/ports'
import { ApiKeyRateLimitError, AppError, rateLimited, unauthorized } from '../usecases/ports'
import { anonymousAuthzContext, type Env } from './platform'
type SessionWithPlugins = {
@@ -16,6 +19,61 @@ type SessionWithPlugins = {
export const authMiddleware = createMiddleware<Env>(async (c, next) => {
const authHeader = c.req.raw.headers.get('Authorization')
if (authHeader?.startsWith('DPoP ')) {
const auth = c.get('auth')
const authContext = await auth.$context
const audience = `${new URL(c.req.url).origin}/api`
let payload: Awaited<
ReturnType<ReturnType<ReturnType<typeof oauthProviderResourceClient>['getActions']>['verifyAccessTokenRequest']>
>
try {
payload = await oauthProviderResourceClient(auth)
.getActions()
.verifyAccessTokenRequest(c.req.raw, {
verifyOptions: { audience, issuer: authContext.baseURL },
dpop: { replayStore: createDpopReplayStore(authContext.internalAdapter) },
})
} catch (error) {
if (error instanceof APIError) throw dpopUnauthorized(audience)
throw error
}
const userId = typeof payload.sub === 'string' ? payload.sub : null
const orgId = typeof payload.zpan_org_id === 'string' ? payload.zpan_org_id : null
const clientId = typeof payload.client_id === 'string' ? payload.client_id : null
const actor = payload.act && typeof payload.act === 'object' ? (payload.act as Record<string, unknown>).sub : null
if (!userId || !orgId || !clientId || typeof actor !== 'string') throw unauthorized('Unauthorized')
if (
typeof payload.jti !== 'string' ||
(await c.get('deps').agentOAuth.isJwtAccessTokenRevoked(c.get('platform').db, payload.jti))
) {
throw dpopUnauthorized(audience)
}
if (await c.get('deps').userAdmin.isBanned(userId)) throw unauthorized('Unauthorized')
const scopes = typeof payload.scope === 'string' ? payload.scope.split(/\s+/).filter(isAuthorizationScope) : []
const grantId = typeof payload.jti === 'string' ? payload.jti : actor
c.set('principal', {
kind: 'agent-oauth',
grantId,
clientId,
orgId,
userId,
scopes,
authMethod: 'dpop',
})
c.set('authzContext', {
credential: 'agent_oauth',
userId,
workspace: { mode: 'bound', orgId },
grantedScopes: new Set(scopes),
actor: { type: 'agent_oauth', ref: actor },
state: { clientId },
})
c.set('userId', userId)
c.set('userRole', null)
c.set('orgId', orgId)
await next()
return
}
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice('Bearer '.length).trim()
const platform = c.get('platform')
@@ -98,32 +156,6 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
await next()
return
}
const agentOAuth = await deps.agentOAuth.verifyAccessToken(platform.db, token)
if (agentOAuth) {
if (await deps.userAdmin.isBanned(agentOAuth.userId)) throw unauthorized('Unauthorized')
c.set('principal', {
kind: 'agent-oauth',
grantId: agentOAuth.grantId,
clientId: agentOAuth.clientId,
orgId: agentOAuth.orgId,
userId: agentOAuth.userId,
scopes: agentOAuth.scopes,
authMethod: 'bearer',
})
c.set('authzContext', {
credential: 'agent_oauth',
userId: agentOAuth.userId,
workspace: { mode: 'bound', orgId: agentOAuth.orgId },
grantedScopes: new Set(agentOAuth.scopes),
actor: { type: 'agent_oauth', ref: agentOAuth.grantId },
state: { clientId: agentOAuth.clientId },
})
c.set('userId', agentOAuth.userId)
c.set('userRole', null)
c.set('orgId', agentOAuth.orgId)
await next()
return
}
const bootstrap = await deps.downloaderBootstrapCredentials.resolve(platform, token, new Date())
if (bootstrap) {
c.set('userId', bootstrap.userId)
@@ -187,3 +219,11 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
await next()
})
function dpopUnauthorized(resource: string): AppError {
return new AppError(401, 'Unauthorized', {
headers: {
'WWW-Authenticate': `DPoP resource_metadata="${new URL('/.well-known/oauth-protected-resource/api', resource).toString()}"`,
},
})
}
-84
View File
@@ -1,84 +0,0 @@
import { AuthorizationScope } from '@shared/authorization'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { authorize, type RouteAuthorizationDeclaration } from './authz'
import { type AuthzContext, type Env, workspaceOrgId } from './platform'
function probeApp(context: AuthzContext, declaration: RouteAuthorizationDeclaration) {
const recordGrantUse = vi.fn(async () => {})
const app = new Hono<Env>()
app.use('/probe', async (c, next) => {
c.set('authzContext', context)
c.set('platform', { db: { kind: 'unit-db' } } as unknown as Env['Variables']['platform'])
c.set('deps', {
agentOAuth: { recordGrantUse },
audit: { record: vi.fn() },
org: {
getMemberRole: vi.fn(async () => 'owner'),
findPersonalOrg: vi.fn(async () => workspaceOrgId(context)),
},
} as unknown as Env['Variables']['deps'])
await next()
})
app.get('/probe', authorize(declaration), (c) => c.json({ ok: true }))
return { app, recordGrantUse }
}
describe('authorize Agent OAuth grant-use tracking', () => {
const context: AuthzContext = {
credential: 'agent_oauth',
userId: 'user-1',
workspace: { mode: 'bound', orgId: 'org-1' },
grantedScopes: new Set([AuthorizationScope.OBJECTS_READ]),
actor: { type: 'agent_oauth', ref: 'grant-1' },
state: { clientId: 'zpan-agent' },
}
it('records actual Agent OAuth use for scoped protected routes', async () => {
const { app, recordGrantUse } = probeApp(context, {
scopes: [AuthorizationScope.OBJECTS_READ],
})
const res = await app.request('/probe')
expect(res.status).toBe(200)
expect(recordGrantUse).toHaveBeenCalledTimes(1)
expect(recordGrantUse).toHaveBeenCalledWith(
{ kind: 'unit-db' },
expect.objectContaining({
grantId: 'grant-1',
userId: 'user-1',
orgId: 'org-1',
now: expect.any(Date),
}),
)
})
it('does not record public access as grant use', async () => {
const { app, recordGrantUse } = probeApp(context, { public: true })
const res = await app.request('/probe')
expect(res.status).toBe(200)
expect(recordGrantUse).not.toHaveBeenCalled()
})
it('does not record non-Agent OAuth protected access as grant use', async () => {
const { app, recordGrantUse } = probeApp(
{
credential: 'session',
userId: 'user-1',
workspace: { mode: 'selected', orgId: 'org-1' },
grantedScopes: null,
actor: { type: 'user', ref: 'user-1' },
state: { firstParty: true },
},
{ scopes: [AuthorizationScope.OBJECTS_READ] },
)
const res = await app.request('/probe')
expect(res.status).toBe(200)
expect(recordGrantUse).not.toHaveBeenCalled()
})
})
-23
View File
@@ -116,7 +116,6 @@ export function authorize(declaration: RouteAuthorizationDeclaration) {
})
if (decision.allowed) {
if (decision.effectiveOrgId) c.set('orgId', decision.effectiveOrgId)
await recordAgentOAuthGrantUse(c, declaration, decision.effectiveOrgId)
await next()
return
}
@@ -159,23 +158,6 @@ function isSafeMethod(method: string): boolean {
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
}
async function recordAgentOAuthGrantUse(
c: Context<Env>,
declaration: RouteAuthorizationDeclaration,
effectiveOrgId: string | null,
) {
const context = c.get('authzContext')
if (context.credential !== 'agent_oauth') return
if (!declaredScopes(declaration).length) return
if (!context.userId || !effectiveOrgId || context.actor?.type !== 'agent_oauth') return
await c.get('deps').agentOAuth.recordGrantUse(c.get('platform').db, {
grantId: context.actor.ref,
userId: context.userId,
orgId: effectiveOrgId,
now: new Date(),
})
}
function allow(effectiveOrgId: string | null): AuthzDecision {
return { allowed: true, effectiveOrgId, reason: 'allowed' }
}
@@ -199,11 +181,6 @@ function shouldAudit(declaration: RouteAuthorizationDeclaration): boolean {
return declaration.auditDenied !== false
}
function declaredScopes(declaration: RouteAuthorizationDeclaration): AuthorizationScope[] {
if ('public' in declaration) return []
return [...declaration.scopes]
}
async function recordDenialAudit(c: Context<Env>, reason: AuthzDenialReason) {
const context = c.get('authzContext')
if (!context.actor) return
+1 -1
View File
@@ -59,7 +59,7 @@ export type AuthPrincipal =
orgId: string
userId: string
scopes: readonly AuthorizationScope[]
authMethod: 'bearer'
authMethod: 'bearer' | 'dpop'
}
| {
kind: 'downloader'
+124 -104
View File
@@ -53,7 +53,83 @@ describe('global OpenAPI document', () => {
expect(html).toContain('/api/openapi.json')
})
it('publishes Agent OAuth security schemes and Restish profiles', async () => {
it('advertises and serves the Arazzo workflow description', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const [rootResponse, workflowResponse, documentResponse] = await Promise.all([
app.request('https://zpan.example/api'),
app.request('https://zpan.example/api/workflows.arazzo.json'),
app.request('https://zpan.example/api/openapi.json'),
])
const root = (await rootResponse.json()) as { workflows?: string }
const workflows = (await workflowResponse.json()) as {
arazzo?: string
$self?: string
sourceDescriptions?: { name?: string; url?: string; type?: string }[]
workflows?: {
workflowId?: string
steps?: { operationId?: string }[]
outputs?: Record<string, string>
}[]
}
const document = (await documentResponse.json()) as {
externalDocs?: { description?: string; url?: string }
paths?: Record<string, Record<string, { operationId?: string }>>
}
expect(rootResponse.status).toBe(200)
expect(rootResponse.headers.get('link')).toContain(
'</api/openapi.json>; rel="service-desc"; type="application/openapi+json"',
)
expect(rootResponse.headers.get('link')).toContain(
'</api/workflows.arazzo.json>; rel="describedby"; type="application/vnd.oai.workflows+json"',
)
expect(root.workflows).toBe('/api/workflows.arazzo.json')
expect(workflowResponse.status).toBe(200)
expect(workflowResponse.headers.get('content-type')).toBe('application/vnd.oai.workflows+json; version=1.1.0')
expect(workflows).toMatchObject({
arazzo: '1.1.0',
$self: 'https://zpan.example/api/workflows.arazzo.json',
sourceDescriptions: [{ name: 'zpan', url: './openapi.json', type: 'openapi' }],
})
expect(workflows.workflows?.map((workflow) => workflow.workflowId)).toEqual([
'prepareDirectFileUpload',
'refreshDirectFileUploadParts',
'completeDirectFileUpload',
'abortDirectFileUpload',
])
const workflowOperationIds = workflows.workflows
?.flatMap((workflow) => workflow.steps ?? [])
.map((step) => step.operationId)
expect(workflowOperationIds).toEqual([
'createObject',
'presignObjectUploadParts',
'completeObjectUpload',
'abortObjectUpload',
])
const openApiOperationIds = new Set(
Object.values(document.paths ?? {}).flatMap((path) =>
Object.values(path).flatMap((operation) => operation.operationId ?? []),
),
)
expect(workflowOperationIds?.every((operationId) => operationId && openApiOperationIds.has(operationId))).toBe(true)
expect(workflows.workflows?.[0]?.outputs).toMatchObject({
objectId: '$steps.createUploadDraft.outputs.objectId',
sessionId: '$steps.createUploadDraft.outputs.sessionId',
upload: '$steps.createUploadDraft.outputs.upload',
})
expect(document.externalDocs).toEqual({
description: 'Machine-readable API workflows (Arazzo 1.1)',
url: '/api/workflows.arazzo.json',
})
const headResponse = await app.request('https://zpan.example/api/workflows.arazzo.json', { method: 'HEAD' })
expect(headResponse.status).toBe(200)
expect(headResponse.headers.get('content-type')).toBe('application/vnd.oai.workflows+json; version=1.1.0')
expect(await headResponse.text()).toBe('')
})
it('publishes the external OAuth scope catalog without Restish profiles', 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 {
@@ -63,20 +139,7 @@ describe('global OpenAPI document', () => {
{ type?: string; scheme?: string; flows?: { authorizationCode?: { scopes?: Record<string, string> } } }
>
}
'x-cli-config'?: {
profiles?: Record<
string,
{
credentials?: Record<
string,
{
auth?: { type?: string; params?: Record<string, string> }
satisfies?: string[]
}
>
}
>
}
'x-cli-config'?: unknown
}
expect(doc.components?.securitySchemes?.agentOAuth2).toMatchObject({
@@ -94,86 +157,48 @@ describe('global OpenAPI document', () => {
},
},
})
expect(doc.components?.securitySchemes?.agentApiKey).toMatchObject({ type: 'http', scheme: 'bearer' })
const profiles = doc['x-cli-config']?.profiles
expect(Object.keys(profiles ?? {})).toEqual(['default', 'reader', 'file-manager', 'publisher', 'ci'])
expect(profiles?.reader?.credentials?.agentOAuth2).toMatchObject({
auth: {
type: 'oauth-authorization-code',
params: {
authorize_url: '/api/auth/oauth2/authorize',
token_url: '/api/auth/oauth2/token',
client_id: 'zpan-agent',
redirect_path: '/callback',
scopes: 'openid offline_access objects:read shares:read quota:read storage-usage:read',
expect(doc.components?.securitySchemes?.agentApiKey).toBeUndefined()
expect(doc['x-cli-config']).toBeUndefined()
})
it('publishes a public resource-scope catalog for external controller discovery', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const [catalogResponse, documentResponse] = await Promise.all([
app.request('/api/oauth-resource-scopes'),
app.request('/api/openapi.json'),
])
const catalog = (await catalogResponse.json()) as {
scopes: { value: string; description: string }[]
}
const document = (await documentResponse.json()) as {
paths: Record<string, { get?: { security?: Record<string, string[]>[]; 'x-zpan-auth'?: unknown } }>
}
expect(catalogResponse.status).toBe(200)
expect(catalog.scopes).toEqual(
expect.arrayContaining([
{
value: AuthorizationScope.OBJECTS_CREATE,
description: 'Create folders and upload objects',
},
},
satisfies: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.SHARES_READ,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
],
})
expect(profiles?.default?.credentials?.agentOAuth2).toEqual(profiles?.reader?.credentials?.agentOAuth2)
expect(profiles?.['file-manager']?.credentials?.agentOAuth2).toMatchObject({
auth: {
type: 'oauth-authorization-code',
params: {
authorize_url: '/api/auth/oauth2/authorize',
token_url: '/api/auth/oauth2/token',
client_id: 'zpan-agent',
redirect_path: '/callback',
scopes:
'openid offline_access objects:read objects:create objects:update objects:delete shares:read quota:read storage-usage:read',
{
value: AuthorizationScope.OBJECTS_UPDATE,
description: 'Rename, move, and copy objects',
},
},
satisfies: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
AuthorizationScope.OBJECTS_DELETE,
AuthorizationScope.SHARES_READ,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
],
})
expect(profiles?.publisher?.credentials?.agentOAuth2).toMatchObject({
auth: {
type: 'oauth-authorization-code',
params: {
authorize_url: '/api/auth/oauth2/authorize',
token_url: '/api/auth/oauth2/token',
client_id: 'zpan-agent',
redirect_path: '/callback',
scopes:
'openid offline_access objects:read shares:read shares:create shares:delete quota:read storage-usage:read',
]),
)
expect(document.paths['/api/oauth-resource-scopes']?.get).toMatchObject({
security: [
{
agentOAuth2: expect.arrayContaining([
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
]),
},
},
satisfies: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.SHARES_READ,
AuthorizationScope.SHARES_CREATE,
AuthorizationScope.SHARES_DELETE,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
],
})
expect(profiles?.default?.credentials?.agentOAuth2?.auth?.params).toMatchObject({
client_id: 'zpan-agent',
redirect_path: '/callback',
})
expect(profiles?.ci?.credentials?.agentApiKey).toMatchObject({
auth: { type: 'bearer', params: { token: 'env:ZPAN_AGENT_API_KEY' } },
satisfies: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
AuthorizationScope.OBJECTS_DELETE,
AuthorizationScope.SHARES_READ,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
{},
],
'x-zpan-auth': { public: true, scopes: [] },
})
})
@@ -330,7 +355,7 @@ describe('global OpenAPI document', () => {
}
})
it('emits Agent OAuth and API-key security only for Agent-grantable protected scopes', () => {
it('leaves externally authorized operations unbound so delegated hooks can authenticate them', () => {
const route = authRoute(
{
scopes: [AuthorizationScope.OBJECTS_CREATE],
@@ -344,11 +369,7 @@ describe('global OpenAPI document', () => {
},
) as { security?: unknown }
expect(route.security).toEqual([
{ agentOAuth2: [AuthorizationScope.OBJECTS_CREATE] },
{ agentApiKey: [AuthorizationScope.OBJECTS_CREATE] },
{ cookieAuth: [] },
])
expect(route.security).toBeUndefined()
})
it('hides non-agent scoped policies from MCP without hiding them from Restish', () => {
@@ -422,8 +443,6 @@ describe('global OpenAPI document', () => {
}
const ignoredOperations = [
doc.paths['/api/workspaces/{orgId}/agent-api-keys']?.get,
doc.paths['/api/workspaces/{orgId}/agent-api-keys']?.post,
doc.paths['/api/agent-oauth-grants']?.get,
doc.paths['/api/agent-oauth-grants/{grantId}']?.delete,
doc.paths['/api/site/storages']?.post,
@@ -474,12 +493,12 @@ describe('global OpenAPI document', () => {
expect(doc.paths['/api/objects']?.post).toMatchObject({
operationId: 'createObject',
security: [
{ agentOAuth2: [AuthorizationScope.OBJECTS_CREATE] },
{ agentApiKey: [AuthorizationScope.OBJECTS_CREATE] },
{ cookieAuth: [] },
],
'x-zpan-auth': {
public: false,
scopes: [AuthorizationScope.OBJECTS_CREATE],
},
})
expect(doc.paths['/api/objects']?.post?.security).toBeUndefined()
expect(doc.paths['/api/objects']?.post?.responses?.['201']).toBeDefined()
expect(doc.paths['/api/objects']?.post?.requestBody).toBeDefined()
expect(doc.paths['/api/objects']?.post?.requestBody?.content?.['application/json']?.schema).toMatchObject({
@@ -512,6 +531,7 @@ describe('global OpenAPI document', () => {
'requiredHeaders',
'urls',
'parts',
'workflow',
],
},
},
+68
View File
@@ -44,6 +44,7 @@ const AUTH_SCHEMA_SQL = `
CREATE INDEX IF NOT EXISTS session_userId_idx ON session(user_id);
CREATE TABLE IF NOT EXISTS account (
id TEXT PRIMARY KEY,
issuer TEXT NOT NULL DEFAULT '',
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
@@ -58,6 +59,7 @@ const AUTH_SCHEMA_SQL = `
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS account_userId_idx ON account(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS account_issuer_providerAccountId_unique ON account(issuer, account_id);
CREATE TABLE IF NOT EXISTS verification (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
@@ -114,6 +116,15 @@ const AUTH_SCHEMA_SQL = `
CREATE INDEX IF NOT EXISTS deviceCode_device_code_idx ON deviceCode(device_code);
CREATE INDEX IF NOT EXISTS deviceCode_user_code_idx ON deviceCode(user_code);
CREATE INDEX IF NOT EXISTS deviceCode_status_idx ON deviceCode(status);
CREATE TABLE IF NOT EXISTS jwks (
id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
alg TEXT,
crv TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
expires_at INTEGER
);
CREATE TABLE IF NOT EXISTS oauthClient (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL UNIQUE,
@@ -137,17 +148,49 @@ const AUTH_SCHEMA_SQL = `
software_statement TEXT,
redirect_uris TEXT NOT NULL,
post_logout_redirect_uris TEXT,
backchannel_logout_uri TEXT,
backchannel_logout_session_required INTEGER,
token_endpoint_auth_method TEXT,
jwks TEXT,
jwks_uri TEXT,
grant_types TEXT,
response_types TEXT,
public INTEGER,
type TEXT,
require_pkce INTEGER,
dpop_bound_access_tokens INTEGER DEFAULT 0,
reference_id TEXT,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS oauthClient_client_id_idx ON oauthClient(client_id);
CREATE INDEX IF NOT EXISTS oauthClient_user_id_idx ON oauthClient(user_id);
CREATE TABLE IF NOT EXISTS oauthResource (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
access_token_ttl INTEGER,
refresh_token_ttl INTEGER,
signing_algorithm TEXT,
signing_key_id TEXT,
allowed_scopes TEXT,
custom_claims TEXT,
dpop_bound_access_tokens_required INTEGER DEFAULT 0,
disabled INTEGER DEFAULT 0,
policy_version INTEGER DEFAULT 1,
metadata TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS oauthResource_identifier_idx ON oauthResource(identifier);
CREATE TABLE IF NOT EXISTS oauthClientResource (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oauthClient(client_id) ON DELETE CASCADE,
resource_id TEXT NOT NULL REFERENCES oauthResource(identifier) ON DELETE CASCADE,
metadata TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS oauthClientResource_client_id_idx ON oauthClientResource(client_id);
CREATE INDEX IF NOT EXISTS oauthClientResource_resource_id_idx ON oauthClientResource(resource_id);
CREATE TABLE IF NOT EXISTS oauthRefreshToken (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
@@ -155,10 +198,17 @@ const AUTH_SCHEMA_SQL = `
session_id TEXT REFERENCES session(id) ON DELETE SET NULL,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
reference_id TEXT,
authorization_code_id TEXT,
resources TEXT,
requested_user_info_claims TEXT,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
revoked INTEGER,
rotated_at INTEGER,
rotation_replay_response TEXT,
rotation_replay_expires_at INTEGER,
auth_time INTEGER,
confirmation TEXT,
scopes TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS oauthRefreshToken_client_id_idx ON oauthRefreshToken(client_id);
@@ -172,9 +222,14 @@ const AUTH_SCHEMA_SQL = `
session_id TEXT REFERENCES session(id) ON DELETE SET NULL,
user_id TEXT REFERENCES user(id) ON DELETE CASCADE,
reference_id TEXT,
authorization_code_id TEXT,
resources TEXT,
requested_user_info_claims TEXT,
refresh_id TEXT REFERENCES oauthRefreshToken(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
revoked INTEGER,
confirmation TEXT,
scopes TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS oauthAccessToken_client_id_idx ON oauthAccessToken(client_id);
@@ -187,6 +242,8 @@ const AUTH_SCHEMA_SQL = `
client_id TEXT NOT NULL REFERENCES oauthClient(client_id) ON DELETE CASCADE,
user_id TEXT REFERENCES user(id) ON DELETE CASCADE,
reference_id TEXT,
resources TEXT,
requested_user_info_claims TEXT,
scopes TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
last_used_at INTEGER,
@@ -194,6 +251,17 @@ const AUTH_SCHEMA_SQL = `
);
CREATE INDEX IF NOT EXISTS oauthConsent_client_id_idx ON oauthConsent(client_id);
CREATE INDEX IF NOT EXISTS oauthConsent_user_id_idx ON oauthConsent(user_id);
CREATE TABLE IF NOT EXISTS oauthClientAssertion (
id TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS oauthJwtRevocation (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS oauthJwtRevocation_expires_at_idx ON oauthJwtRevocation(expires_at);
CREATE TABLE IF NOT EXISTS downloader_bootstrap_credentials (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
-111
View File
@@ -1,111 +0,0 @@
import { AGENT_GRANTABLE_API_KEY_SCOPES } from '@shared/api-key-templates'
import type {
AgentApiKeyCreated,
AgentApiKeyCreateInput,
AgentApiKeyList,
AgentApiKeyRotateInput,
AgentGrantableScope,
} from '@shared/schemas'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import { badRequest, conflict, forbidden, notFound } from './ports'
const MAX_AGENT_API_KEY_AGE_MS = 365 * 24 * 60 * 60 * 1000
const AGENT_GRANTABLE_SCOPE_SET = new Set<string>(AGENT_GRANTABLE_API_KEY_SCOPES)
export async function listAgentApiKeys(
deps: Pick<Deps, 'apiKeys' | 'org'>,
db: Database,
input: { userId: string; orgId: string; page: number; pageSize: number; now?: Date },
): Promise<AgentApiKeyList> {
await requireWorkspaceManager(deps, input.userId, input.orgId)
const items = await deps.apiKeys.listAgentApiKeys(db, input.userId, input.orgId, input.now ?? new Date())
const offset = (input.page - 1) * input.pageSize
return {
items: items.slice(offset, offset + input.pageSize),
total: items.length,
page: input.page,
pageSize: input.pageSize,
}
}
export async function createAgentApiKey(
deps: Pick<Deps, 'apiKeys' | 'org'>,
db: Database,
input: { userId: string; orgId: string; body: AgentApiKeyCreateInput; now?: Date },
): Promise<AgentApiKeyCreated> {
const now = input.now ?? new Date()
await requireWorkspaceManager(deps, input.userId, input.orgId)
return deps.apiKeys.issueAgentApiKey(db, {
name: input.body.name,
orgId: input.orgId,
userId: input.userId,
scopes: normalizeScopes(input.body.scopes),
expiresAt: parseExpiresAt(input.body.expiresAt, now),
})
}
export async function rotateAgentApiKey(
deps: Pick<Deps, 'apiKeys' | 'org'>,
db: Database,
input: { userId: string; orgId: string; keyId: string; body: AgentApiKeyRotateInput; now?: Date },
): Promise<AgentApiKeyCreated> {
const now = input.now ?? new Date()
await requireWorkspaceManager(deps, input.userId, input.orgId)
const existing = await deps.apiKeys.getAgentApiKey(db, input.userId, input.orgId, input.keyId, now)
if (!existing) throw notFound('Agent API key not found')
if (existing.status !== 'active') {
throw conflict('Only active Agent API keys can be rotated', 'AGENT_API_KEY_NOT_ACTIVE')
}
return deps.apiKeys.issueAgentApiKey(db, {
name: input.body.name?.trim() || `${existing.name} rotation`,
orgId: input.orgId,
userId: input.userId,
scopes: normalizeScopes(input.body.scopes ?? existing.scopes),
expiresAt: parseExpiresAt(input.body.expiresAt ?? existing.expiresAt, now),
revokeKeyId: existing.id,
})
}
export async function revokeAgentApiKey(
deps: Pick<Deps, 'apiKeys' | 'org'>,
db: Database,
input: { userId: string; orgId: string; keyId: string; now?: Date },
): Promise<void> {
await requireWorkspaceManager(deps, input.userId, input.orgId)
const existing = await deps.apiKeys.getAgentApiKey(
db,
input.userId,
input.orgId,
input.keyId,
input.now ?? new Date(),
)
if (!existing) throw notFound('Agent API key not found')
await deps.apiKeys.revokeAgentApiKey(db, input.keyId)
}
async function requireWorkspaceManager(deps: Pick<Deps, 'org'>, userId: string, orgId: string): Promise<void> {
if (!(await deps.org.canManageAgentAccess(userId, orgId))) {
throw forbidden('Owner or admin access to the workspace is required')
}
}
function parseExpiresAt(value: string, now: Date): Date {
const expiresAt = new Date(value)
if (Number.isNaN(expiresAt.getTime())) throw badRequest('Invalid expiry')
if (expiresAt <= now) throw badRequest('Agent API key expiry must be in the future')
if (expiresAt.getTime() - now.getTime() > MAX_AGENT_API_KEY_AGE_MS) {
throw badRequest('Agent API key expiry cannot exceed one year')
}
return expiresAt
}
function normalizeScopes(scopes: readonly string[]): AgentGrantableScope[] {
const unique = new Set(scopes)
if (unique.size !== scopes.length) throw badRequest('Duplicate Agent API key scopes are not allowed')
const normalized = [...unique] as AgentGrantableScope[]
if (normalized.some((scope) => !AGENT_GRANTABLE_SCOPE_SET.has(scope))) {
throw badRequest('Agent API key scope is not grantable')
}
return normalized
}
+100 -53
View File
@@ -1,8 +1,11 @@
import { AGENT_OAUTH_CLIENT_ID, AGENT_OAUTH_CLIENT_NAME } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { describe, expect, it, vi } from 'vitest'
import { getAgentOAuthConsentContext } from './agent-oauth-consent'
import type { OrgRepo } from './ports'
import type { AgentOAuthGateway, OrgRepo } from './ports'
const db = {} as never
const CLIENT_ID = 'dynamic-client'
const CLIENT_NAME = 'FlareAuth'
function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
return {
@@ -17,9 +20,38 @@ function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
}
}
function deps(
orgRepo: OrgRepo,
client: {
clientId?: string
clientName?: string
redirectUris?: string[]
scopes?: string[]
} = {},
) {
return {
org: orgRepo,
agentOAuth: {
findClient: vi.fn(async () => ({
clientId: client.clientId ?? CLIENT_ID,
clientName: client.clientName ?? CLIENT_NAME,
disabled: false,
redirectUris: client.redirectUris ?? ['http://127.0.0.1:8484/callback'],
responseTypes: ['code'],
scopes: client.scopes ?? [
'openid',
'offline_access',
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.QUOTA_READ,
],
})),
} as unknown as AgentOAuthGateway,
}
}
function oauthQuery(overrides: Record<string, string> = {}) {
return new URLSearchParams({
client_id: AGENT_OAUTH_CLIENT_ID,
client_id: CLIENT_ID,
redirect_uri: 'http://127.0.0.1:8484/callback',
response_type: 'code',
scope: `openid offline_access ${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ}`,
@@ -28,20 +60,45 @@ function oauthQuery(overrides: Record<string, string> = {}) {
}
describe('Agent OAuth consent usecase', () => {
it('builds server-owned consent context for the active workspace', async () => {
it('resolves a dynamically registered client instead of hard-coding its identity', async () => {
const dynamicQuery = oauthQuery({
client_id: 'dynamic-client',
redirect_uri: 'https://broker.example.com/oauth/callback',
})
await expect(
getAgentOAuthConsentContext(
{ org: org() },
deps(org(), {
clientId: 'dynamic-client',
clientName: 'Broker',
redirectUris: ['https://broker.example.com/oauth/callback'],
}),
{
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery(),
oauthQuery: dynamicQuery,
},
),
).resolves.toMatchObject({
clientId: 'dynamic-client',
clientName: 'Broker',
redirectUri: 'https://broker.example.com/oauth/callback',
})
})
it('builds server-owned consent context for the active workspace', async () => {
await expect(
getAgentOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery(),
}),
).resolves.toEqual({
clientId: AGENT_OAUTH_CLIENT_ID,
clientName: AGENT_OAUTH_CLIENT_NAME,
clientId: CLIENT_ID,
clientName: CLIENT_NAME,
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
@@ -56,15 +113,13 @@ describe('Agent OAuth consent usecase', () => {
it('keeps the active workspace id when the workspace name is unavailable', async () => {
await expect(
getAgentOAuthConsentContext(
{ org: org({ getOrgNames: vi.fn(async () => new Map()) }) },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery(),
},
),
getAgentOAuthConsentContext(deps(org({ getOrgNames: vi.fn(async () => new Map()) })), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery(),
}),
).resolves.toMatchObject({
workspace: { id: 'org-1', name: null },
})
@@ -72,55 +127,47 @@ describe('Agent OAuth consent usecase', () => {
it('rejects requests that are not the managed authorization-code client flow', async () => {
await expect(
getAgentOAuthConsentContext(
{ org: org() },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery({ response_type: 'token' }),
},
),
getAgentOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery({ response_type: 'token' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
})
it('rejects untrusted redirect URIs and non-grantable scopes', async () => {
await expect(
getAgentOAuthConsentContext(
{ org: org() },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery({ redirect_uri: 'https://evil.example/callback' }),
},
),
getAgentOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery({ redirect_uri: 'https://evil.example/callback' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
await expect(
getAgentOAuthConsentContext(
{ org: org() },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery({ scope: 'objects:purge' }),
},
),
getAgentOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery({ scope: 'objects:purge' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
})
it('rejects missing or inaccessible workspaces', async () => {
await expect(
getAgentOAuthConsentContext(
{ org: org({ canReadOrg: vi.fn(async () => false) }) },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery(),
},
),
getAgentOAuthConsentContext(deps(org({ canReadOrg: vi.fn(async () => false) })), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery(),
}),
).rejects.toMatchObject({ httpStatus: 403 })
})
})
+21 -13
View File
@@ -1,19 +1,17 @@
import {
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_CLIENT_ID,
AGENT_OAUTH_CLIENT_NAME,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
AGENT_OAUTH_STANDARD_SCOPES,
RESTISH_OAUTH_REDIRECT_URIS,
} from '@shared/agent-oauth'
import { isAuthorizationScope } from '@shared/authorization'
import { type AgentGrantableScope, type AgentOAuthConsentContext, agentGrantableScopeSchema } from '@shared/schemas'
import { type AgentOAuthConsentContext, type OAuthResourceScope, oauthResourceScopeSchema } from '@shared/schemas'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import { badRequest, forbidden } from './ports'
export async function getAgentOAuthConsentContext(
deps: Pick<Deps, 'org'>,
input: { userId: string; orgId: string | null; requestUrl: string; oauthQuery: string },
deps: Pick<Deps, 'agentOAuth' | 'org'>,
input: { db: Database; userId: string; orgId: string | null; requestUrl: string; oauthQuery: string },
): Promise<AgentOAuthConsentContext> {
const params = new URLSearchParams(input.oauthQuery)
const clientId = params.get('client_id')
@@ -21,10 +19,16 @@ export async function getAgentOAuthConsentContext(
const responseType = params.get('response_type')
const scopeValue = params.get('scope') ?? ''
if (clientId !== AGENT_OAUTH_CLIENT_ID || responseType !== 'code' || !redirectUri) {
if (!clientId || responseType !== 'code' || !redirectUri) {
throw badRequest('Invalid Agent OAuth request')
}
if (!RESTISH_OAUTH_REDIRECT_URIS.includes(redirectUri as (typeof RESTISH_OAUTH_REDIRECT_URIS)[number])) {
const client = await deps.agentOAuth.findClient(input.db, clientId)
if (
!client ||
client.disabled ||
!client.responseTypes.includes('code') ||
!client.redirectUris.includes(redirectUri)
) {
throw badRequest('Invalid Agent OAuth redirect URI')
}
@@ -32,8 +36,12 @@ export async function getAgentOAuthConsentContext(
const standardScopes = requestedScopes.filter((scope) =>
(AGENT_OAUTH_STANDARD_SCOPES as readonly string[]).includes(scope),
)
const scopes = requestedScopes.filter(isAgentGrantableScope)
if (scopes.length === 0 || requestedScopes.length !== standardScopes.length + scopes.length) {
const scopes = requestedScopes.filter(isOAuthResourceScope)
if (
scopes.length === 0 ||
requestedScopes.length !== standardScopes.length + scopes.length ||
requestedScopes.some((scope) => !client.scopes.includes(scope))
) {
throw badRequest('Invalid Agent OAuth scope')
}
@@ -45,7 +53,7 @@ export async function getAgentOAuthConsentContext(
return {
clientId,
clientName: AGENT_OAUTH_CLIENT_NAME,
clientName: client.clientName,
instanceOrigin: new URL(input.requestUrl).origin,
workspace: { id: orgId, name: names.get(orgId) ?? null },
scopes,
@@ -58,6 +66,6 @@ export async function getAgentOAuthConsentContext(
}
}
function isAgentGrantableScope(scope: string): scope is AgentGrantableScope {
return isAuthorizationScope(scope) && agentGrantableScopeSchema.safeParse(scope).success
function isOAuthResourceScope(scope: string): scope is OAuthResourceScope {
return isAuthorizationScope(scope) && oauthResourceScopeSchema.safeParse(scope).success
}
+8 -7
View File
@@ -6,11 +6,11 @@ const db = {} as never
function gateway(overrides: Partial<AgentOAuthGateway> = {}): AgentOAuthGateway {
return {
ensureSystemClient: vi.fn(),
assertLiveGrant: vi.fn(),
verifyAccessToken: vi.fn(),
findClient: vi.fn(),
listRegisteredApplications: vi.fn(),
revokeJwtAccessToken: vi.fn(),
isJwtAccessTokenRevoked: vi.fn(),
listGrants: vi.fn(async () => []),
recordGrantUse: vi.fn(),
revokeGrant: vi.fn(async () => true),
...overrides,
}
@@ -35,7 +35,8 @@ describe('Agent OAuth grant usecases', () => {
listGrants: vi.fn(async () => [
{
id: 'grant-1',
clientId: 'zpan-agent',
clientId: 'dynamic-client',
clientName: 'FlareAuth',
userId: 'user-1',
orgId: 'org-1',
scopes: [],
@@ -49,8 +50,8 @@ describe('Agent OAuth grant usecases', () => {
items: [
{
id: 'grant-1',
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
clientId: 'dynamic-client',
clientName: 'FlareAuth',
userId: 'user-1',
orgId: 'org-1',
workspaceName: 'Personal',
+5 -5
View File
@@ -1,8 +1,8 @@
import {
type AgentGrantableScope,
type AgentOAuthGrant as AgentOAuthGrantDTO,
agentGrantableScopeSchema,
agentOAuthGrantDTO,
type OAuthResourceScope,
oauthResourceScopeSchema,
} from '@shared/schemas'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
@@ -19,15 +19,15 @@ export async function listAgentOAuthGrants(
items: items.map((item) =>
agentOAuthGrantDTO({
...item,
scopes: item.scopes.filter(isAgentGrantableScope),
scopes: item.scopes.filter(isOAuthResourceScope),
workspaceName: orgNames.get(item.orgId) ?? null,
}),
),
}
}
function isAgentGrantableScope(scope: string): scope is AgentGrantableScope {
return agentGrantableScopeSchema.safeParse(scope).success
function isOAuthResourceScope(scope: string): scope is OAuthResourceScope {
return oauthResourceScopeSchema.safeParse(scope).success
}
export async function revokeAgentOAuthGrant(
+34 -4
View File
@@ -337,6 +337,8 @@ describe('object usecase', () => {
url: 'https://up',
expiresAt: out.upload.presignedExpiresAt,
headers: { 'content-type': 'image/jpeg' },
offset: 0,
length: 2048,
},
])
expect(out.matter.status).toBe('draft')
@@ -467,8 +469,22 @@ describe('object usecase', () => {
expect(out.upload.requiredHeaders).toEqual({})
expect(out.upload.urls).toEqual(['https://part-1', 'https://part-2'])
expect(out.upload.parts).toEqual([
{ partNumber: 1, url: 'https://part-1', expiresAt: out.upload.presignedExpiresAt, headers: {} },
{ partNumber: 2, url: 'https://part-2', expiresAt: out.upload.presignedExpiresAt, headers: {} },
{
partNumber: 1,
url: 'https://part-1',
expiresAt: out.upload.presignedExpiresAt,
headers: {},
offset: 0,
length: multipartPartSize,
},
{
partNumber: 2,
url: 'https://part-2',
expiresAt: out.upload.presignedExpiresAt,
headers: {},
offset: multipartPartSize,
length: 1,
},
])
expect(createMultipartUpload).toHaveBeenCalled()
expect(presignUploadPart).toHaveBeenCalledWith(
@@ -1001,8 +1017,22 @@ describe('object usecase', () => {
expect(out.uploadId).toBe('mp-1')
expect(out.partCount).toBe(3)
expect(out.parts).toEqual([
{ partNumber: 3, url: 'https://part-3', expiresAt: out.presignedExpiresAt, headers: {} },
{ partNumber: 1, url: 'https://part-1', expiresAt: out.presignedExpiresAt, headers: {} },
{
partNumber: 3,
url: 'https://part-3',
expiresAt: out.presignedExpiresAt,
headers: {},
offset: 200,
length: 50,
},
{
partNumber: 1,
url: 'https://part-1',
expiresAt: out.presignedExpiresAt,
headers: {},
offset: 0,
length: 100,
},
])
expect(presignUploadPart).toHaveBeenCalledWith(storage, 'key/d1', 'mp-1', 3, UPLOAD_PRESIGNED_URL_TTL_SECONDS)
})
+41
View File
@@ -265,6 +265,8 @@ async function prepareUpload(
url: await deps.s3.presignUpload(storage, storageKey, contentType, UPLOAD_PRESIGNED_URL_TTL_SECONDS),
expiresAt: presignedExpiresAt,
headers,
offset: 0,
length: size,
},
]
} else {
@@ -285,6 +287,8 @@ async function prepareUpload(
url: await deps.s3.presignUploadPart(storage, storageKey, mpId, i + 1, UPLOAD_PRESIGNED_URL_TTL_SECONDS),
expiresAt: presignedExpiresAt,
headers: {},
offset: i * partSize,
length: Math.min(partSize, size - i * partSize),
})),
)
}
@@ -310,6 +314,39 @@ async function prepareUpload(
requiredHeaders: uploadId == null ? headers : {},
urls: parts.map((part) => part.url),
parts,
workflow: uploadWorkflow(params.objectId, record.id),
}
}
function uploadWorkflow(objectId: string, sessionId: string): ObjectUploadInstructions['workflow'] {
const sessionPath = `/api/objects/${objectId}/uploads/${sessionId}`
return {
version: '1',
upload: {
method: 'PUT',
urlField: 'parts[].url',
headersField: 'parts[].headers',
fileOffsetField: 'parts[].offset',
contentLengthField: 'parts[].length',
etagHeader: 'ETag',
},
complete: {
operationId: 'completeObjectUpload',
method: 'POST',
path: `${sessionPath}/completions`,
partsBodyField: 'parts',
},
rePresign: {
operationId: 'presignObjectUploadParts',
method: 'POST',
path: `${sessionPath}/parts`,
partNumbersBodyField: 'partNumbers',
},
abort: {
operationId: 'abortObjectUpload',
method: 'DELETE',
path: sessionPath,
},
}
}
@@ -408,6 +445,8 @@ export async function presignUploadSessionParts(
),
expiresAt: presignedExpiresAt,
headers,
offset: 0,
length: matter.size ?? 0,
})),
)
return {
@@ -433,6 +472,8 @@ export async function presignUploadSessionParts(
),
expiresAt: presignedExpiresAt,
headers: {},
offset: (partNumber - 1) * record.partSize,
length: Math.min(record.partSize, (matter.size ?? 0) - (partNumber - 1) * record.partSize),
})),
)
return {
+25 -15
View File
@@ -1,17 +1,10 @@
import type { AuthorizationScope } from '@shared/authorization'
import type { Database } from '../../platform/interface'
export interface VerifiedAgentOAuthToken {
grantId: string
userId: string
orgId: string
clientId: string
scopes: AuthorizationScope[]
}
export interface AgentOAuthGrant {
id: string
clientId: string
clientName: string
userId: string
orgId: string
scopes: AuthorizationScope[]
@@ -19,14 +12,31 @@ export interface AgentOAuthGrant {
lastUsedAt: string | null
}
export interface AgentOAuthClient {
clientId: string
clientName: string
disabled: boolean
redirectUris: string[]
responseTypes: string[]
scopes: string[]
}
export interface RegisteredOAuthApplication {
clientId: string
name: string
uri: string | null
redirectUris: string[]
grantTypes: string[]
scopes: string[]
disabled: boolean
createdAt: string
}
export interface AgentOAuthGateway {
ensureSystemClient(db: Database): Promise<void>
assertLiveGrant(
db: Database,
input: { userId: string; clientId: string; orgId?: string; scopes: readonly string[] },
): Promise<void>
verifyAccessToken(db: Database, token: string): Promise<VerifiedAgentOAuthToken | null>
findClient(db: Database, clientId: string): Promise<AgentOAuthClient | null>
listRegisteredApplications(db: Database): Promise<RegisteredOAuthApplication[]>
revokeJwtAccessToken(db: Database, token: string): Promise<void>
isJwtAccessTokenRevoked(db: Database, tokenId: string): Promise<boolean>
listGrants(db: Database, userId: string): Promise<AgentOAuthGrant[]>
recordGrantUse(db: Database, input: { grantId: string; userId: string; orgId: string; now: Date }): Promise<void>
revokeGrant(db: Database, input: { userId: string; grantId: string; now: Date }): Promise<boolean>
}
-15
View File
@@ -1,6 +1,5 @@
import type { ApiKeyScope } from '@shared/api-key-templates'
import type { ApiKeyPermissions, AuthorizationScope } from '@shared/authorization'
import type { AgentApiKey, AgentApiKeyCreated, AgentGrantableScope } from '@shared/schemas'
import type { Database } from '../../platform/interface'
export interface VerifiedApiKey {
@@ -41,18 +40,4 @@ export interface ApiKeyGateway {
): Promise<VerifiedApiKey | null>
hasApiKeyPermission(permissions: ApiKeyPermissions | null | undefined, resource: string, action: string): boolean
hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope: AuthorizationScope): boolean
listAgentApiKeys(db: Database, userId: string, orgId: string, now: Date): Promise<AgentApiKey[]>
getAgentApiKey(db: Database, userId: string, orgId: string, keyId: string, now: Date): Promise<AgentApiKey | null>
issueAgentApiKey(
db: Database,
input: {
name: string
userId: string
orgId: string
scopes: AgentGrantableScope[]
expiresAt: Date
revokeKeyId?: string
},
): Promise<AgentApiKeyCreated>
revokeAgentApiKey(db: Database, keyId: string): Promise<void>
}
+40 -1
View File
@@ -1,10 +1,12 @@
import { FREE_SOCIAL_LOGIN_LIMIT } from '@shared/constants'
import type { BindingState } from '@shared/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LicenseBindingRepo, SystemOption, SystemOptionsRepo } from '../ports'
import type { Database } from '../../platform/interface'
import type { AgentOAuthGateway, LicenseBindingRepo, SystemOption, SystemOptionsRepo } from '../ports'
import {
type AuthProviderDeps,
deleteAuthProvider,
listAuthProviderSettings,
listAuthProviders,
listPublicAuthProviders,
type UpsertProviderInput,
@@ -122,6 +124,43 @@ describe('auth-provider usecase', () => {
})
})
describe('listAuthProviderSettings', () => {
it('combines provider configuration with dynamically registered applications', async () => {
const { deps } = makeDeps({
listByPrefix: async () => [
row({
providerId: 'github',
type: 'builtin',
clientId: 'a',
clientSecret: 'super-secret-value',
enabled: true,
}),
],
})
const registeredApplications = [
{
clientId: 'dynamic-client',
name: 'Build Agent',
uri: null,
redirectUris: ['http://127.0.0.1/callback'],
grantTypes: ['authorization_code'],
scopes: ['objects:read'],
disabled: false,
createdAt: '2026-07-30T12:00:00.000Z',
},
]
const listRegisteredApplications = vi.fn(async () => registeredApplications)
const agentOAuth = { listRegisteredApplications } as unknown as AgentOAuthGateway
const db = {} as Database
const result = await listAuthProviderSettings({ ...deps, agentOAuth }, db, listOptions)
expect(result.items).toHaveLength(1)
expect(result.registeredApplications).toEqual(registeredApplications)
expect(listRegisteredApplications).toHaveBeenCalledWith(db)
})
})
describe('upsertAuthProvider', () => {
it('creates a new builtin provider under the free limit and stores it', async () => {
edition(COMMUNITY)
+14
View File
@@ -20,7 +20,9 @@ import {
import type { SiteConfig } from '@shared/schemas'
import type { AuthProvider } from '@shared/types'
import { hasFeature } from '../../domain/licensing'
import type { Database } from '../../platform/interface'
import {
type AgentOAuthGateway,
type AppError,
badRequest,
type CacheService,
@@ -105,6 +107,18 @@ export async function listAuthProviders(
return { items }
}
export async function listAuthProviderSettings(
deps: Pick<AuthProviderDeps, 'systemOptions'> & { agentOAuth: AgentOAuthGateway },
db: Database,
{ authOrigin }: { authOrigin: string },
) {
const [{ items }, registeredApplications] = await Promise.all([
listAuthProviders(deps, { authOrigin }),
deps.agentOAuth.listRegisteredApplications(db),
])
return { items, registeredApplications }
}
export async function listPublicAuthProviders(
deps: Pick<AuthProviderDeps, 'systemOptions'>,
): Promise<SiteConfig['auth']['providers']> {
+29 -6
View File
@@ -1,10 +1,33 @@
import { AGENT_GRANTABLE_API_KEY_SCOPES } from './api-key-templates'
import { AuthorizationScope } from './authorization'
export const AGENT_OAUTH_CLIENT_ID = 'zpan-agent'
export const AGENT_OAUTH_CLIENT_NAME = 'ZPan Agent'
export const AGENT_OAUTH_ACCESS_TOKEN_SECONDS = 15 * 60
export const AGENT_OAUTH_REFRESH_TOKEN_SECONDS = 30 * 24 * 60 * 60
export const RESTISH_OAUTH_REDIRECT_URIS = ['http://localhost:8484/callback', 'http://127.0.0.1:8484/callback'] as const
export const AGENT_OAUTH_ACTOR_TOKEN_SECONDS = 5 * 60
export const JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
export const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'
export const OAUTH_ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'
export const AGENT_ACTOR_RESOURCE = 'urn:zpan:oauth:agent-actor'
export const AGENT_OAUTH_STANDARD_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
export const AGENT_OAUTH_SCOPES = [...AGENT_OAUTH_STANDARD_SCOPES, ...AGENT_GRANTABLE_API_KEY_SCOPES] as const
export const AGENT_OAUTH_RESOURCE_SCOPES = [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
AuthorizationScope.OBJECTS_DELETE,
AuthorizationScope.SHARES_READ,
AuthorizationScope.SHARES_CREATE,
AuthorizationScope.SHARES_DELETE,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
] as const
export const AGENT_OAUTH_SCOPES = [...AGENT_OAUTH_STANDARD_SCOPES, ...AGENT_OAUTH_RESOURCE_SCOPES] as const
export const AGENT_OAUTH_SCOPE_DESCRIPTIONS: Record<(typeof AGENT_OAUTH_RESOURCE_SCOPES)[number], string> = {
[AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects',
[AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects',
[AuthorizationScope.OBJECTS_UPDATE]: 'Rename, move, and copy objects',
[AuthorizationScope.OBJECTS_DELETE]: 'Soft-delete objects',
[AuthorizationScope.SHARES_READ]: 'List and inspect shares',
[AuthorizationScope.SHARES_CREATE]: 'Create public shares',
[AuthorizationScope.SHARES_DELETE]: 'Revoke shares',
[AuthorizationScope.QUOTA_READ]: 'Inspect workspace quota',
[AuthorizationScope.STORAGE_USAGE_READ]: 'Inspect workspace storage usage',
}
-50
View File
@@ -6,7 +6,6 @@ export const ApiKeyTemplate = {
IHOST: 'ihost',
WEBDAV: 'webdav',
REMOTE_DOWNLOAD: 'remote-download',
AGENT: 'agent',
} as const
export type ApiKeyTemplate = (typeof ApiKeyTemplate)[keyof typeof ApiKeyTemplate]
@@ -60,59 +59,10 @@ export const REMOTE_DOWNLOAD_API_KEY_PERMISSIONS = {
]),
} satisfies ApiKeyPermissions
export const AGENT_GRANTABLE_API_KEY_SCOPES = [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
AuthorizationScope.OBJECTS_DELETE,
AuthorizationScope.SHARES_READ,
AuthorizationScope.SHARES_CREATE,
AuthorizationScope.SHARES_DELETE,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
] as const
export const AGENT_API_KEY_PERMISSIONS = scopePermissions(AGENT_GRANTABLE_API_KEY_SCOPES)
export const AgentApiKeyShortcut = {
READER: 'reader',
FILE_MANAGER: 'file-manager',
PUBLISHER: 'publisher',
} as const
export type AgentApiKeyShortcut = (typeof AgentApiKeyShortcut)[keyof typeof AgentApiKeyShortcut]
export const AGENT_API_KEY_SHORTCUT_SCOPES = {
[AgentApiKeyShortcut.READER]: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.SHARES_READ,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
],
[AgentApiKeyShortcut.FILE_MANAGER]: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
AuthorizationScope.OBJECTS_DELETE,
AuthorizationScope.SHARES_READ,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
],
[AgentApiKeyShortcut.PUBLISHER]: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.SHARES_READ,
AuthorizationScope.SHARES_CREATE,
AuthorizationScope.SHARES_DELETE,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
],
} as const satisfies Record<AgentApiKeyShortcut, readonly AuthorizationScope[]>
export const API_KEY_TEMPLATE_PERMISSIONS = {
[ApiKeyTemplate.IHOST]: IHOST_API_KEY_PERMISSIONS,
[ApiKeyTemplate.WEBDAV]: WEBDAV_API_KEY_PERMISSIONS,
[ApiKeyTemplate.REMOTE_DOWNLOAD]: REMOTE_DOWNLOAD_API_KEY_PERMISSIONS,
[ApiKeyTemplate.AGENT]: AGENT_API_KEY_PERMISSIONS,
} satisfies Record<ApiKeyTemplate, ApiKeyPermissions>
export const API_KEY_TEMPLATES = Object.values(ApiKeyTemplate)
+3 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { AGENT_GRANTABLE_API_KEY_SCOPES, WEBDAV_API_KEY_PERMISSIONS } from './api-key-templates'
import { AGENT_OAUTH_RESOURCE_SCOPES } from './agent-oauth'
import { WEBDAV_API_KEY_PERMISSIONS } from './api-key-templates'
import {
AuthorizationScope,
authorizationScope,
@@ -21,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(AGENT_GRANTABLE_API_KEY_SCOPES).not.toContain(AuthorizationScope.OBJECTS_PURGE)
expect(AGENT_OAUTH_RESOURCE_SCOPES).not.toContain(AuthorizationScope.OBJECTS_PURGE)
expect(scopePermissions([AuthorizationScope.OBJECTS_DELETE])).toEqual({ objects: ['delete'] })
})
-4
View File
@@ -14,10 +14,6 @@ export const AuthorizationScope = {
DOWNLOAD_TASKS_CREATE: 'download-tasks:create',
DOWNLOAD_TASKS_CANCEL: 'download-tasks:cancel',
SITE_ANALYTICS_READ: 'site-analytics:read',
AGENT_API_KEYS_READ: 'agent-api-keys:read',
AGENT_API_KEYS_CREATE: 'agent-api-keys:create',
AGENT_API_KEYS_UPDATE: 'agent-api-keys:update',
AGENT_API_KEYS_DELETE: 'agent-api-keys:delete',
AGENT_OAUTH_GRANTS_READ: 'agent-oauth-grants:read',
AGENT_OAUTH_GRANTS_CREATE: 'agent-oauth-grants:create',
AGENT_OAUTH_GRANTS_DELETE: 'agent-oauth-grants:delete',
-70
View File
@@ -1,70 +0,0 @@
import { z } from 'zod'
import {
AGENT_API_KEY_SHORTCUT_SCOPES,
AGENT_GRANTABLE_API_KEY_SCOPES,
AgentApiKeyShortcut,
} from '../api-key-templates'
import { AuthorizationScope } from '../authorization'
export const agentGrantableScopeSchema = z.enum(AGENT_GRANTABLE_API_KEY_SCOPES)
export type AgentGrantableScope = z.infer<typeof agentGrantableScopeSchema>
export const agentApiKeyShortcutSchema = z.enum(Object.values(AgentApiKeyShortcut))
export type AgentApiKeyShortcutInput = z.infer<typeof agentApiKeyShortcutSchema>
export const agentApiKeyCreateSchema = z.object({
name: z.string().trim().min(1).max(120),
scopes: z.array(agentGrantableScopeSchema).min(1),
expiresAt: z.string().datetime(),
})
export type AgentApiKeyCreateInput = z.infer<typeof agentApiKeyCreateSchema>
export const agentApiKeyRotateSchema = agentApiKeyCreateSchema.partial({ name: true, scopes: true, expiresAt: true })
export type AgentApiKeyRotateInput = z.infer<typeof agentApiKeyRotateSchema>
export const agentApiKeyStatusSchema = z.enum(['active', 'expired', 'revoked', 'inaccessible'])
export type AgentApiKeyStatus = z.infer<typeof agentApiKeyStatusSchema>
export const agentApiKeySchema = z.object({
id: z.string(),
name: z.string(),
orgId: z.string(),
workspaceName: z.string().nullable(),
scopes: z.array(agentGrantableScopeSchema),
createdAt: z.string(),
expiresAt: z.string(),
lastUsedAt: z.string().nullable(),
status: agentApiKeyStatusSchema,
})
export type AgentApiKey = z.infer<typeof agentApiKeySchema>
export const agentApiKeyListSchema = z.object({
items: z.array(agentApiKeySchema),
total: z.number().int(),
page: z.number().int(),
pageSize: z.number().int(),
})
export type AgentApiKeyList = z.infer<typeof agentApiKeyListSchema>
export const agentApiKeyCreatedSchema = z.object({
key: z.string(),
item: agentApiKeySchema,
})
export type AgentApiKeyCreated = z.infer<typeof agentApiKeyCreatedSchema>
export const agentApiKeyShortcutOptions = Object.entries(AGENT_API_KEY_SHORTCUT_SCOPES).map(([id, scopes]) => ({
id: id as AgentApiKeyShortcutInput,
scopes: [...scopes],
}))
export const agentScopeLabels = {
[AuthorizationScope.OBJECTS_READ]: 'settings.agentAccess.scope.objectsRead',
[AuthorizationScope.OBJECTS_CREATE]: 'settings.agentAccess.scope.objectsCreate',
[AuthorizationScope.OBJECTS_UPDATE]: 'settings.agentAccess.scope.objectsUpdate',
[AuthorizationScope.OBJECTS_DELETE]: 'settings.agentAccess.scope.objectsDelete',
[AuthorizationScope.SHARES_READ]: 'settings.agentAccess.scope.sharesRead',
[AuthorizationScope.SHARES_CREATE]: 'settings.agentAccess.scope.sharesCreate',
[AuthorizationScope.SHARES_DELETE]: 'settings.agentAccess.scope.sharesDelete',
[AuthorizationScope.QUOTA_READ]: 'settings.agentAccess.scope.quotaRead',
[AuthorizationScope.STORAGE_USAGE_READ]: 'settings.agentAccess.scope.storageUsageRead',
} as const satisfies Record<AgentGrantableScope, string>
+6 -11
View File
@@ -1,10 +1,6 @@
import { z } from 'zod'
import {
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_CLIENT_NAME,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
} from '../agent-oauth'
import { agentGrantableScopeSchema } from './agent-api-keys'
import { AGENT_OAUTH_ACCESS_TOKEN_SECONDS, AGENT_OAUTH_REFRESH_TOKEN_SECONDS } from '../agent-oauth'
import { oauthResourceScopeSchema } from './oauth-resource'
export const agentOAuthGrantStatusSchema = z.enum(['active'])
export type AgentOAuthGrantStatus = z.infer<typeof agentOAuthGrantStatusSchema>
@@ -12,11 +8,11 @@ export type AgentOAuthGrantStatus = z.infer<typeof agentOAuthGrantStatusSchema>
export const agentOAuthGrantSchema = z.object({
id: z.string(),
clientId: z.string(),
clientName: z.string().default(AGENT_OAUTH_CLIENT_NAME),
clientName: z.string(),
userId: z.string(),
orgId: z.string(),
workspaceName: z.string().nullable(),
scopes: z.array(agentGrantableScopeSchema),
scopes: z.array(oauthResourceScopeSchema),
createdAt: z.string(),
lastUsedAt: z.string().nullable(),
status: agentOAuthGrantStatusSchema,
@@ -34,7 +30,7 @@ export const agentOAuthConsentContextSchema = z.object({
id: z.string(),
name: z.string().nullable(),
}),
scopes: z.array(agentGrantableScopeSchema),
scopes: z.array(oauthResourceScopeSchema),
standardScopes: z.array(z.string()),
redirectUri: z.string(),
grantLifetime: z.object({
@@ -60,10 +56,9 @@ export const agentOAuthConsentResultSchema = z.object({
})
export type AgentOAuthConsentResult = z.infer<typeof agentOAuthConsentResultSchema>
export function agentOAuthGrantDTO(input: Omit<AgentOAuthGrant, 'clientName' | 'status'>): AgentOAuthGrant {
export function agentOAuthGrantDTO(input: Omit<AgentOAuthGrant, 'status'>): AgentOAuthGrant {
return {
...input,
clientName: AGENT_OAUTH_CLIENT_NAME,
status: 'active',
}
}
+34 -23
View File
@@ -9,28 +9,6 @@ export {
adminAnalyticsTrafficSchema,
adminOverviewSchema,
} from './admin-analytics'
export type {
AgentApiKey,
AgentApiKeyCreated,
AgentApiKeyCreateInput,
AgentApiKeyList,
AgentApiKeyRotateInput,
AgentApiKeyShortcutInput,
AgentApiKeyStatus,
AgentGrantableScope,
} from './agent-api-keys'
export {
agentApiKeyCreatedSchema,
agentApiKeyCreateSchema,
agentApiKeyListSchema,
agentApiKeyRotateSchema,
agentApiKeySchema,
agentApiKeyShortcutOptions,
agentApiKeyShortcutSchema,
agentApiKeyStatusSchema,
agentGrantableScopeSchema,
agentScopeLabels,
} from './agent-api-keys'
export type {
AgentOAuthConsentContext,
AgentOAuthConsentContextRequest,
@@ -50,7 +28,6 @@ export {
agentOAuthGrantSchema,
agentOAuthGrantStatusSchema,
} from './agent-oauth-grants'
export type {
AnnouncementInput,
AnnouncementStatus,
@@ -185,6 +162,8 @@ export {
} from './errors'
export type { ListNotificationsQuery } from './notification'
export { listNotificationsQuerySchema } from './notification'
export type { OAuthResourceScope } from './oauth-resource'
export { oauthResourceScopeLabels, oauthResourceScopeSchema } from './oauth-resource'
export type { CursorPage, CursorPageQuery, Page, PageQuery } from './pagination'
export { cursorPageQuerySchema, cursorPageSchema, pageQuerySchema, pageSchema } from './pagination'
export type { PublicProfile, PublicProfileShare, PublicUser } from './profile'
@@ -318,6 +297,37 @@ export const presignedObjectUploadPartSchema = z.object({
url: z.string(),
expiresAt: z.string(),
headers: z.record(z.string(), z.string()),
offset: z.number().int().min(0),
length: z.number().int().min(0),
})
export const objectUploadWorkflowSchema = z.object({
version: z.literal('1'),
upload: z.object({
method: z.literal('PUT'),
urlField: z.literal('parts[].url'),
headersField: z.literal('parts[].headers'),
fileOffsetField: z.literal('parts[].offset'),
contentLengthField: z.literal('parts[].length'),
etagHeader: z.literal('ETag'),
}),
complete: z.object({
operationId: z.literal('completeObjectUpload'),
method: z.literal('POST'),
path: z.string(),
partsBodyField: z.literal('parts'),
}),
rePresign: z.object({
operationId: z.literal('presignObjectUploadParts'),
method: z.literal('POST'),
path: z.string(),
partNumbersBodyField: z.literal('partNumbers'),
}),
abort: z.object({
operationId: z.literal('abortObjectUpload'),
method: z.literal('DELETE'),
path: z.string(),
}),
})
// The upload instructions returned by POST /objects for a file draft. File bytes
@@ -334,6 +344,7 @@ export const objectUploadInstructionsSchema = z.object({
requiredHeaders: z.record(z.string(), z.string()),
urls: z.array(z.string()),
parts: z.array(presignedObjectUploadPartSchema),
workflow: objectUploadWorkflowSchema,
})
export const presignObjectUploadPartsResponseSchema = z.object({
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod'
import { AGENT_OAUTH_RESOURCE_SCOPES } from '../agent-oauth'
import { AuthorizationScope } from '../authorization'
export const oauthResourceScopeSchema = z.enum(AGENT_OAUTH_RESOURCE_SCOPES)
export type OAuthResourceScope = z.infer<typeof oauthResourceScopeSchema>
export const oauthResourceScopeLabels = {
[AuthorizationScope.OBJECTS_READ]: 'settings.agentAccess.scope.objectsRead',
[AuthorizationScope.OBJECTS_CREATE]: 'settings.agentAccess.scope.objectsCreate',
[AuthorizationScope.OBJECTS_UPDATE]: 'settings.agentAccess.scope.objectsUpdate',
[AuthorizationScope.OBJECTS_DELETE]: 'settings.agentAccess.scope.objectsDelete',
[AuthorizationScope.SHARES_READ]: 'settings.agentAccess.scope.sharesRead',
[AuthorizationScope.SHARES_CREATE]: 'settings.agentAccess.scope.sharesCreate',
[AuthorizationScope.SHARES_DELETE]: 'settings.agentAccess.scope.sharesDelete',
[AuthorizationScope.QUOTA_READ]: 'settings.agentAccess.scope.quotaRead',
[AuthorizationScope.STORAGE_USAGE_READ]: 'settings.agentAccess.scope.storageUsageRead',
} as const satisfies Record<OAuthResourceScope, string>
+44
View File
@@ -200,6 +200,18 @@ export interface AuthProvider {
export interface AuthProviderList {
items: AuthProvider[]
callbackBaseUri: string
registeredApplications?: RegisteredOAuthApplication[]
}
export interface RegisteredOAuthApplication {
clientId: string
name: string
uri: string | null
redirectUris: string[]
grantTypes: string[]
scopes: string[]
disabled: boolean
createdAt: string
}
export interface CursorPage<T> {
@@ -423,6 +435,37 @@ export interface ObjectUploadPartDescriptor {
url: string
expiresAt: string
headers: Record<string, string>
offset: number
length: number
}
export interface ObjectUploadWorkflow {
version: '1'
upload: {
method: 'PUT'
urlField: 'parts[].url'
headersField: 'parts[].headers'
fileOffsetField: 'parts[].offset'
contentLengthField: 'parts[].length'
etagHeader: 'ETag'
}
complete: {
operationId: 'completeObjectUpload'
method: 'POST'
path: string
partsBodyField: 'parts'
}
rePresign: {
operationId: 'presignObjectUploadParts'
method: 'POST'
path: string
partNumbersBodyField: 'partNumbers'
}
abort: {
operationId: 'abortObjectUpload'
method: 'DELETE'
path: string
}
}
// The upload instructions returned by POST /objects for a file draft: the
@@ -439,6 +482,7 @@ export interface ObjectUploadInstructions {
requiredHeaders: Record<string, string>
urls: string[]
parts: ObjectUploadPartDescriptor[]
workflow: ObjectUploadWorkflow
}
export type BackgroundJobStatus = 'queued' | 'running' | 'completed' | 'failed' | 'canceled'
-69
View File
@@ -1,69 +0,0 @@
---
name: zpan
description: Manage ZPan files through Restish v2.3+ and the trusted restish-zpan upload plugin.
---
# ZPan Agent Skill
This Skill targets the ZPan v2.9 Restish integration.
Use this Skill when an agent needs to browse, inspect, move, copy, delete,
upload, download, share, revoke shares, check quota, or inspect background
tasks on a ZPan instance.
## Operating Boundary
ZPan file management uses two surfaces:
- Generated Restish OpenAPI commands for ordinary API operations.
- `restish zpan-upload` from the `restish-zpan` plugin for every local file
upload.
Do not read local file bytes, orchestrate upload parts, handle storage response
tags, loop part retries, or expose storage upload URLs. The upload plugin owns
local file streaming, upload state, storage response capture, retry, resume,
abort, and checkpoint cleanup.
## Start Here
1. Confirm the ZPan origin with the user before connecting or mutating data.
2. Confirm the Restish API name. Use `zpan` unless the user already has a
different local API name.
3. Require Restish v2.3 or later.
4. Connect exactly one OpenAPI document: `<origin>/api/openapi.json`.
5. Select the least-privilege profile that fits the task:
`reader`, `file-manager`, `publisher`, or `ci`.
6. Sync the Restish API before use when it was connected previously.
Use [references/setup.md](references/setup.md) for install, connect, sync, and
profile selection.
## Workflow Routing
- Browsing, inspecting, folders, move/copy/rename, delete, download links,
shares, quota, and tasks: use [references/file-workflows.md](references/file-workflows.md).
- Local uploads: use [references/uploads.md](references/uploads.md).
- CI or unattended automation with an Agent API key:
use [references/ci.md](references/ci.md).
- Optional MCP transport for reviewed ordinary operations:
use [references/mcp.md](references/mcp.md).
- Release or preview acceptance evidence:
use [references/acceptance.md](references/acceptance.md).
## Safety Rules
Confirm before:
- choosing a target workspace;
- overwriting, replacing, or retrying conflict handling;
- soft deleting files or folders;
- permanently purging trash;
- creating public shares;
- installing executable Restish plugins.
Never ask the user to paste a bearer token. Interactive use goes through
browser OAuth authorization code + PKCE. CI use relies on an environment-backed
Agent API key profile.
Keep results bounded. Prefer compact object IDs, names, paths, URLs, quota
effects, task state, and upload state over full raw responses.
-48
View File
@@ -1,48 +0,0 @@
# Acceptance Evidence
For release or preview verification, record the exact origin, Restish version,
plugin source, profile, and commands used. Do not record credentials.
## Fresh-Machine Interactive Flow
Verify:
1. Install Restish v2.3 or later.
2. Confirm the ZPan origin and local API name.
3. Connect `/api/openapi.json`.
4. Sync the API.
5. Approve installing `restish-zpan` from `saltbo/zpan`.
6. Run a safe reader operation and complete browser OAuth authorization code +
PKCE consent.
7. List objects, upload a local file with `restish zpan-upload`, interrupt and
resume one upload when practical, inspect the uploaded object, create a
public share, revoke the share, and check quota.
For pre-release PR or preview acceptance, a v2.9 GitHub release asset does not
exist yet. Build the already-reviewed `cmd/restish-zpan` source at the exact PR
head, install that trusted local executable for the acceptance run, and
separately verify that the release workflow produces the asset names expected
by Restish. Record that this was a source-build acceptance.
After v2.9 is released, repeat the install step through the user-facing release
path:
```sh
restish plugin install saltbo/zpan zpan
```
## CI Flow
Verify:
1. Create a workspace-scoped Agent API key in ZPan settings.
2. Store it in `ZPAN_AGENT_API_KEY`.
3. Use the `ci` Restish profile without token copy/paste.
4. Run list and upload operations.
5. Attempt an operation outside the key scope and confirm `403`.
## Static Contract
Run the repository Skill contract check before release. It verifies the required
Restish setup, upload plugin, profile, safety, CI, and MCP guidance while
guarding against removed or unsafe v2.9 workflows.
-22
View File
@@ -1,22 +0,0 @@
# CI and Unattended Automation
Use the `ci` Restish profile for unattended jobs. The profile reads the Agent
API key from the environment and does not require token copy/paste:
```sh
export ZPAN_AGENT_API_KEY="$ZPAN_AGENT_API_KEY"
restish --rsh-profile ci zpan list-objects --parent root --page-size 50
RSH_PROFILE=ci restish zpan-upload --api zpan --profile ci --parent releases ./dist/app.tar.gz
```
The key must be created by a user in ZPan Agent Access settings, scoped to one
workspace, named for the environment, given explicit permissions, and stored in
the CI secret store. The plaintext key is shown once by ZPan and should never be
posted into chat, logs, issue comments, or PR output.
Use separate keys for separate environments. Expired or revoked keys are
terminal; create a new key when the job needs a different lifetime.
When a job receives `403`, report the missing operation and expected scope. Do
not broaden the requested scopes automatically. The user should decide whether
to issue a new key or approve a broader scope set.
-75
View File
@@ -1,75 +0,0 @@
# Ordinary File Workflows
Use generated Restish OpenAPI commands for ordinary ZPan operations. Run
`restish api sync zpan` before relying on operation names from an older local
connection.
## List and Inspect
Use a reader-capable profile for browse and inspect operations:
```sh
restish --rsh-profile reader zpan list-objects --parent root --page-size 50
restish --rsh-profile reader zpan get-object obj_123
restish --rsh-profile reader zpan get-user-quota user_123
restish --rsh-profile reader zpan get-storage-usage
```
Keep list limits explicit and summarize IDs, names, paths, sizes, and relevant
URLs. Do not dump unbounded trees.
## Create Folders, Move, Copy, and Rename
Use `file-manager` for object mutations:
```sh
restish --rsh-profile file-manager zpan create-object 'name: releases, parent: root, type: folder, dirtype: 1'
restish --rsh-profile file-manager zpan update-object obj_123 'name: release.zip, onConflict: fail'
restish --rsh-profile file-manager zpan transfer-object obj_123 'mode: move, targetOrgId: org_123, targetParent: folder_456'
restish --rsh-profile file-manager zpan copy-object obj_123 'parent: folder_456, onConflict: fail'
```
Before writes, confirm the workspace and target folder. Before overwrite or
replace behavior, confirm the conflict policy.
## Delete and Purge
Soft delete requires `objects:delete`:
```sh
restish --rsh-profile file-manager zpan delete-object obj_123
```
Confirm destructive intent before soft delete. Permanent trash purge is more
destructive, must be confirmed separately, and is outside the v2.9 Agent
OAuth/API-key profile templates because it requires `objects:purge` on an
authorized human/operator surface. Do not attempt purge through this Skill or
invent an `operator` Restish profile. Ask the user to complete permanent purge
in an authorized operator surface instead.
Return soft-deleted object IDs and any quota effect reported by the API.
## Public Sharing
Use `publisher` for public shares:
```sh
restish --rsh-profile publisher zpan create-share 'matterId: obj_123, kind: landing, private: false'
restish --rsh-profile publisher zpan list-shares --page-size 50
restish --rsh-profile publisher zpan revoke-share share_token_123 'status: revoked'
```
Confirm before creating public shares. Summaries may include share IDs, public
URLs, expiry, and revocation state, but should not include credentials.
## Tasks
Use generated task operations for status checks:
```sh
restish --rsh-profile file-manager zpan list-download-tasks --page-size 25
restish --rsh-profile file-manager zpan get-download-task task_123
restish --rsh-profile file-manager zpan list-download-task-events task_123
```
Summarize state, progress, and errors. Keep event output bounded.
-20
View File
@@ -1,20 +0,0 @@
# Optional Restish MCP
Restish MCP is optional and only for reviewed ordinary OpenAPI operations. It is
not the upload transport. Local file uploads still use `restish zpan-upload`.
Default to read-only MCP:
```sh
restish plugin install rest-sh/restish mcp
restish mcp serve zpan --operations list-objects,get-object,list-shares,get-user-quota,get-storage-usage
```
Enable write tools only after reviewing the exact operation allowlist. Do not
allow upload control-plane operation IDs through MCP.
Keep results bounded. Do not route file bytes, storage upload URLs, bearer
tokens, cookies, API keys, or checkpoint contents through MCP results.
Do not expose authentication, administration, billing, entitlement, membership,
or credential-management operations through MCP.
-77
View File
@@ -1,77 +0,0 @@
# ZPan Restish Setup
## Confirm Origin and API Name
Before connecting, ask the user to confirm:
- the ZPan origin, for example `https://files.example.com`;
- the local Restish API name, normally `zpan`;
- the intended workspace if the next operation reads or changes workspace data.
Use one OpenAPI document only:
```sh
restish api connect zpan https://files.example.com/api/openapi.json --replace --yes
```
The `--yes` here approves replacing the Restish API connection after the user
has confirmed the origin. It does not approve plugin installation.
For an existing connection, sync before use:
```sh
restish api sync zpan
```
## Restish Version
Require Restish v2.3 or later:
```sh
restish --version
```
Stop and ask the user to upgrade if the version is older than v2.3.
## Profiles and Scopes
Reader, File manager, and Publisher are Restish convenience profiles that
expand to explicit scopes. They are not server-side roles or route names.
| Profile | Use for | Scope set |
| --- | --- | --- |
| `reader` | Browse, inspect, download links, quota | `objects:read`, `shares:read`, `quota:read`, `storage-usage:read` |
| `file-manager` | Reader plus create folders, upload, move, copy, rename, soft delete | Reader scopes plus `objects:create`, `objects:update`, `objects:delete` |
| `publisher` | Reader plus create and revoke public shares | Reader scopes plus `shares:create`, `shares:delete` |
| `ci` | Unattended file-management automation | Environment-backed `agentApiKey` with file-manager scopes |
Prefer the narrowest profile:
```sh
restish --rsh-profile reader zpan list-objects --page-size 50
restish --rsh-profile file-manager zpan list-objects --page-size 50
restish --rsh-profile publisher zpan list-shares --page-size 50
```
The first safe OAuth-backed command may open the browser for authorization code
+ PKCE consent. Restish owns token storage, refresh, logout, and redacted auth
diagnostics.
Use `--rsh-no-browser` only when the authorization-code callback can still be
completed manually.
## Upload Plugin Trust Gate
Install `restish-zpan` only after telling the user that Restish plugins are
trusted local executable code and asking them to approve this source:
```sh
restish plugin install saltbo/zpan zpan
```
This shorthand is the post-v2.9-release user path. For pre-release preview
acceptance, follow [acceptance.md](acceptance.md) and label the trusted local
source build explicitly.
Do not add a silent approval flag to plugin installation. After installation,
confirm that `restish zpan-upload` is available before using upload workflows.
-65
View File
@@ -1,65 +0,0 @@
# Upload Workflows
Every local file upload must use the Restish command plugin:
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent root ./artifact.zip
```
The Skill must not implement upload chunking or upload orchestration.
`restish-zpan` validates `createObject`, `presignObjectUploadParts`,
`completeObjectUpload`, and `abortObjectUpload`, then creates or resumes ZPan
upload sessions through Restish delegated HTTP, streams file parts from disk to
storage, records storage responses, retries parts, and removes safe checkpoints
after completion.
## Before Uploading
Confirm:
- the target workspace;
- the target folder or parent object ID;
- whether a same-name destination should fail, rename, or replace;
- plugin trust if `restish zpan-upload` is not installed yet.
Install only after explicit source approval:
```sh
restish plugin install saltbo/zpan zpan
```
## Upload
Use the selected Restish host profile, plugin profile, and API name explicitly.
For plugin delegated HTTP on Restish v2.3, set `RSH_PROFILE` to the same value
as the plugin `--profile` flag. The environment selects the host credential;
the flag separately selects spec validation and checkpoint identity.
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent folder_456 ./release.tar.gz
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent folder_456 ./release.tar.gz release-linux.tar.gz
```
If the plugin supports a conflict flag in the installed version, pass only the
user-approved policy.
## Resume and Abort
Resume interrupted local uploads through the plugin:
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --resume ./release.tar.gz
```
Abort an upload only after confirmation:
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --abort ./release.tar.gz
```
## Output
Return a compact summary with object ID, object URL or share URL when relevant,
parent ID, upload mode, part count, bytes uploaded, task state, and quota effect
when reported. Do not expose storage upload URLs, bearer tokens, API keys,
cookies, or checkpoint contents.
-48
View File
@@ -1,48 +0,0 @@
Feature: Agent API keys
Workspace-scoped Agent API keys provide a CI and unattended-service credential
path. Keys are owned by one authorizing user, bound to one workspace, grant only
explicit Agent scopes, expire, and are revealed only once.
@agent-api-keys/lifecycle @api
Scenario: A user manages a personal workspace Agent API key
Given an authenticated personal workspace owner
When they create, list, rotate, and revoke an Agent API key
Then the plaintext key is returned only on create or rotation
And revoked keys stop working immediately
@agent-api-keys/team-file-ops @api
Scenario: A team Agent API key performs granted file operations
Given a team workspace owner creates an Agent API key with file read and create scopes
When the owner later becomes an editor
Then the key can list files and create folders in that workspace
@agent-api-keys/management-role @api
Scenario: Team credential management is restricted to owners and admins
Given a team workspace member
When an editor tries to list or create Agent API keys
Then the API denies credential management
And an owner or admin can manage Agent API keys
@agent-api-keys/scope-boundary @api
Scenario: Agent API keys cannot request non-Agent scopes
Given an authenticated workspace editor
When they request image-hosting or raw Better Auth Agent permissions
Then the API rejects the key creation request
@agent-api-keys/denials @api
Scenario: Agent API keys fail closed
Given a workspace Agent API key
When the key is missing scope, crosses workspaces, is revoked, expires, or its owner is banned
Then protected APIs reject the request
@agent-api-keys/role-reduction @api
Scenario: Agent API keys recheck current workspace role
Given a team Agent API key created by an owner
When the owner is reduced to viewer
Then management and editor-only file operations are denied
@agent-api-keys/terminal-rotation @api
Scenario: Expired and revoked Agent API keys are terminal
Given an expired or revoked Agent API key
When an owner tries to rotate it
Then the API rejects rotation and requires a new key
@@ -0,0 +1,62 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { listAuthProviders } from '@/lib/api'
const providersQueryKey = ['admin', 'auth-providers'] as const
export function RegisteredOAuthApplicationsSection() {
const { t } = useTranslation()
const { data, isLoading } = useQuery({
queryKey: providersQueryKey,
queryFn: listAuthProviders,
})
const applications = data?.registeredApplications ?? []
return (
<section className="space-y-3">
<div>
<h2 className="font-semibold text-lg">{t('admin.auth.registeredApplications')}</h2>
<p className="text-muted-foreground text-sm">{t('admin.auth.registeredApplicationsDescription')}</p>
</div>
{isLoading ? (
<p className="text-muted-foreground text-sm">{t('common.loading')}</p>
) : applications.length === 0 ? (
<div className="rounded-md border px-4 py-10 text-center text-muted-foreground text-sm">
{t('admin.auth.noRegisteredApplications')}
</div>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t('admin.auth.application')}</TableHead>
<TableHead>{t('admin.auth.clientId')}</TableHead>
<TableHead>{t('admin.auth.redirectUri')}</TableHead>
<TableHead>{t('admin.auth.grants')}</TableHead>
<TableHead>{t('admin.auth.enabled')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{applications.map((application) => (
<TableRow key={application.clientId}>
<TableCell className="font-medium">{application.name}</TableCell>
<TableCell className="max-w-52 truncate font-mono text-xs" title={application.clientId}>
{application.clientId}
</TableCell>
<TableCell className="max-w-72 truncate text-xs" title={application.redirectUris.join(', ')}>
{application.redirectUris.join(', ')}
</TableCell>
<TableCell className="max-w-72 text-xs">{application.grantTypes.join(', ')}</TableCell>
<TableCell>
{application.disabled ? t('admin.auth.statusDisabled') : t('admin.auth.statusEnabled')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</section>
)
}
@@ -42,7 +42,37 @@ function makeUpload(urls: string[], partSize: number): ObjectUploadInstructions
url,
expiresAt: '2026-01-01T00:15:00.000Z',
headers: {},
offset: index * partSize,
length: partSize,
})),
workflow: {
version: '1',
upload: {
method: 'PUT',
urlField: 'parts[].url',
headersField: 'parts[].headers',
fileOffsetField: 'parts[].offset',
contentLengthField: 'parts[].length',
etagHeader: 'ETag',
},
complete: {
operationId: 'completeObjectUpload',
method: 'POST',
path: '/api/objects/object-1/upload/completions',
partsBodyField: 'parts',
},
rePresign: {
operationId: 'presignObjectUploadParts',
method: 'POST',
path: '/api/objects/object-1/upload/parts',
partNumbersBodyField: 'partNumbers',
},
abort: {
operationId: 'abortObjectUpload',
method: 'DELETE',
path: '/api/objects/object-1/upload',
},
},
}
}
+12 -37
View File
@@ -912,6 +912,12 @@
"admin.auth.copyCallbackUri": "Copy callback URI",
"admin.auth.callbackUriCopied": "Callback URI copied",
"admin.auth.clientId": "Client ID",
"admin.auth.registeredApplications": "Registered Applications",
"admin.auth.registeredApplicationsDescription": "OAuth clients registered dynamically with this ZPan instance. Registrations are active immediately.",
"admin.auth.noRegisteredApplications": "No dynamically registered applications.",
"admin.auth.application": "Application",
"admin.auth.redirectUri": "Redirect URI",
"admin.auth.grants": "Grant types",
"admin.auth.clientIdPlaceholder": "OAuth client ID",
"admin.auth.clientSecret": "Client Secret",
"admin.auth.clientSecretPlaceholder": "OAuth client secret",
@@ -1202,19 +1208,8 @@
"settings.apiKeys.orgRequired": "Select a workspace before creating this API key.",
"settings.apiKeys.manage": "Manage API Keys",
"settings.agentAccess.section": "Agent Access",
"settings.agentAccess.description": "Manage workspace-scoped Agent API keys for CI and unattended services.",
"settings.agentAccess.description": "Review and revoke delegated OAuth access to your workspaces.",
"settings.agentAccess.workspaceLabel": "Workspace",
"settings.agentAccess.workspacePlaceholder": "Select a workspace",
"settings.agentAccess.create": "Create Key",
"settings.agentAccess.createTitle": "Create Agent API Key",
"settings.agentAccess.createDescription": "Choose a workspace, expiry, and exact scopes.",
"settings.agentAccess.nameLabel": "Name",
"settings.agentAccess.namePlaceholder": "e.g. GitHub Actions deploy",
"settings.agentAccess.expiryLabel": "Expiry",
"settings.agentAccess.shortcutsLabel": "Shortcuts",
"settings.agentAccess.shortcut.reader": "Reader",
"settings.agentAccess.shortcut.file-manager": "File manager",
"settings.agentAccess.shortcut.publisher": "Publisher",
"settings.agentAccess.scope.objectsRead": "Files: read objects",
"settings.agentAccess.scope.objectsCreate": "Files: create objects",
"settings.agentAccess.scope.objectsUpdate": "Files: update objects",
@@ -1224,35 +1219,16 @@
"settings.agentAccess.scope.sharesDelete": "Shares: revoke shares",
"settings.agentAccess.scope.quotaRead": "Quota: read workspace quota",
"settings.agentAccess.scope.storageUsageRead": "Storage usage: read workspace usage",
"settings.agentAccess.colName": "Name",
"settings.agentAccess.colWorkspace": "Workspace",
"settings.agentAccess.colScopes": "Scopes",
"settings.agentAccess.colCreated": "Created",
"settings.agentAccess.colExpires": "Expires",
"settings.agentAccess.colLastUsed": "Last Used",
"settings.agentAccess.colStatus": "Status",
"settings.agentAccess.colActions": "Actions",
"settings.agentAccess.status.active": "Active",
"settings.agentAccess.status.expired": "Expired",
"settings.agentAccess.status.revoked": "Revoked",
"settings.agentAccess.status.inaccessible": "Inaccessible",
"settings.agentAccess.noKeys": "No Agent API keys yet",
"settings.agentAccess.managementRequired": "Owner or admin access is required to manage Agent API keys for this workspace.",
"settings.agentAccess.never": "Never",
"settings.agentAccess.copy": "Copy",
"settings.agentAccess.copied": "Copied",
"settings.agentAccess.createSuccess": "Agent API key created",
"settings.agentAccess.rotate": "Rotate",
"settings.agentAccess.rotateSuccess": "Agent API key rotated",
"settings.agentAccess.revoke": "Revoke",
"settings.agentAccess.revokeTitle": "Revoke Agent API Key",
"settings.agentAccess.revokeConfirm": "Revoke Agent API key \"{{name}}\"? Any services using it will stop immediately.",
"settings.agentAccess.revokeSuccess": "Agent API key revoked",
"settings.agentAccess.revealedTitle": "Save Your Agent API Key",
"settings.agentAccess.revealedWarning": "This is the only time this key will be shown. Store it securely.",
"settings.agentAccess.oauthConsentEyebrow": "Delegated OAuth access",
"settings.agentAccess.oauthConsentTitle": "Authorize ZPan Agent",
"settings.agentAccess.oauthConsentDescription": "Review the exact workspace and scopes Restish will receive before continuing.",
"settings.agentAccess.oauthConsentTitle": "Authorize Application",
"settings.agentAccess.oauthConsentDescription": "Review the exact workspace and scopes this application will receive before continuing.",
"settings.agentAccess.oauthClient": "Client",
"settings.agentAccess.oauthOrigin": "ZPan instance",
"settings.agentAccess.oauthReturn": "Return URL",
@@ -1263,16 +1239,15 @@
"settings.agentAccess.oauthApprove": "Approve Access",
"settings.agentAccess.oauthDeny": "Deny",
"settings.agentAccess.oauthExpiredTitle": "OAuth request expired",
"settings.agentAccess.oauthExpiredDescription": "Start the Restish connection again to create a fresh authorization request.",
"settings.agentAccess.oauthExpiredDescription": "Start the connection again to create a fresh authorization request.",
"settings.agentAccess.oauthConsentFailed": "Could not finish OAuth consent.",
"settings.agentAccess.oauthWorkspaceFailed": "Could not switch workspace.",
"settings.agentAccess.oauthGrantsSection": "Delegated OAuth Grants",
"settings.agentAccess.oauthGrantsDescription": "Manage Restish OAuth grants connected to your workspaces.",
"settings.agentAccess.oauthGrantsDescription": "Manage application OAuth grants connected to your workspaces.",
"settings.agentAccess.oauthNoGrants": "No delegated OAuth grants yet",
"settings.agentAccess.oauthGrantsError": "Could not load delegated OAuth grants.",
"settings.agentAccess.oauthGrantRevokeTitle": "Revoke OAuth Grant",
"settings.agentAccess.oauthGrantRevokeConfirm": "Revoke {{client}} access to {{workspace}}? Active Restish sessions for this workspace will stop immediately.",
"settings.agentAccess.oauthGrantRevokeSuccess": "OAuth grant revoked",
"settings.agentAccess.oauthGrantRevokeConfirm": "Revoke {{client}} access to {{workspace}}? Active sessions for this workspace will stop immediately.",
"settings.appearance.theme.description": "Choose how ZPan looks. Follows your system setting by default.",
"settings.appearance.language.description": "The display language for the app.",
"settings.appearance.autoSaved": "Changes apply immediately.",
+12 -37
View File
@@ -912,6 +912,12 @@
"admin.auth.copyCallbackUri": "复制 Callback URI",
"admin.auth.callbackUriCopied": "Callback URI 已复制",
"admin.auth.clientId": "Client ID",
"admin.auth.registeredApplications": "已注册应用",
"admin.auth.registeredApplicationsDescription": "通过动态注册接入当前 ZPan 实例的 OAuth 客户端。当前版本注册后立即生效。",
"admin.auth.noRegisteredApplications": "暂无动态注册的应用。",
"admin.auth.application": "应用",
"admin.auth.redirectUri": "回调地址",
"admin.auth.grants": "授权类型",
"admin.auth.clientIdPlaceholder": "OAuth client ID",
"admin.auth.clientSecret": "Client Secret",
"admin.auth.clientSecretPlaceholder": "OAuth client secret",
@@ -1202,19 +1208,8 @@
"settings.apiKeys.orgRequired": "创建该 API Key 前请先选择工作区。",
"settings.apiKeys.manage": "管理 API Key",
"settings.agentAccess.section": "Agent Access",
"settings.agentAccess.description": "管理用于 CI 和无人值守服务的工作空间级 Agent API Key。",
"settings.agentAccess.description": "查看并撤销应用对工作空间的 OAuth 委托访问。",
"settings.agentAccess.workspaceLabel": "工作空间",
"settings.agentAccess.workspacePlaceholder": "选择工作空间",
"settings.agentAccess.create": "创建 Key",
"settings.agentAccess.createTitle": "创建 Agent API Key",
"settings.agentAccess.createDescription": "选择工作空间、过期时间和明确权限。",
"settings.agentAccess.nameLabel": "名称",
"settings.agentAccess.namePlaceholder": "例如:GitHub Actions deploy",
"settings.agentAccess.expiryLabel": "过期时间",
"settings.agentAccess.shortcutsLabel": "快捷模板",
"settings.agentAccess.shortcut.reader": "Reader",
"settings.agentAccess.shortcut.file-manager": "File manager",
"settings.agentAccess.shortcut.publisher": "Publisher",
"settings.agentAccess.scope.objectsRead": "文件:读取对象",
"settings.agentAccess.scope.objectsCreate": "文件:创建对象",
"settings.agentAccess.scope.objectsUpdate": "文件:更新对象",
@@ -1224,35 +1219,16 @@
"settings.agentAccess.scope.sharesDelete": "分享:撤销分享",
"settings.agentAccess.scope.quotaRead": "配额:读取工作空间配额",
"settings.agentAccess.scope.storageUsageRead": "存储用量:读取工作空间用量",
"settings.agentAccess.colName": "名称",
"settings.agentAccess.colWorkspace": "工作空间",
"settings.agentAccess.colScopes": "权限",
"settings.agentAccess.colCreated": "创建时间",
"settings.agentAccess.colExpires": "过期时间",
"settings.agentAccess.colLastUsed": "最近使用",
"settings.agentAccess.colStatus": "状态",
"settings.agentAccess.colActions": "操作",
"settings.agentAccess.status.active": "有效",
"settings.agentAccess.status.expired": "已过期",
"settings.agentAccess.status.revoked": "已撤销",
"settings.agentAccess.status.inaccessible": "不可访问",
"settings.agentAccess.noKeys": "暂无 Agent API Key",
"settings.agentAccess.managementRequired": "需要工作空间所有者或管理员权限才能管理 Agent API Key。",
"settings.agentAccess.never": "从未",
"settings.agentAccess.copy": "复制",
"settings.agentAccess.copied": "已复制",
"settings.agentAccess.createSuccess": "Agent API Key 已创建",
"settings.agentAccess.rotate": "轮换",
"settings.agentAccess.rotateSuccess": "Agent API Key 已轮换",
"settings.agentAccess.revoke": "撤销",
"settings.agentAccess.revokeTitle": "撤销 Agent API Key",
"settings.agentAccess.revokeConfirm": "撤销 Agent API Key「{{name}}」?使用该 Key 的服务将立即停止工作。",
"settings.agentAccess.revokeSuccess": "Agent API Key 已撤销",
"settings.agentAccess.revealedTitle": "保存你的 Agent API Key",
"settings.agentAccess.revealedWarning": "该 Key 只会显示一次,请妥善保存。",
"settings.agentAccess.oauthConsentEyebrow": "委托 OAuth 访问",
"settings.agentAccess.oauthConsentTitle": "授权 ZPan Agent",
"settings.agentAccess.oauthConsentDescription": "继续前请确认 Restish 将获得的具体工作空间和权限。",
"settings.agentAccess.oauthConsentTitle": "授权应用",
"settings.agentAccess.oauthConsentDescription": "继续前请确认该应用将获得的具体工作空间和权限。",
"settings.agentAccess.oauthClient": "客户端",
"settings.agentAccess.oauthOrigin": "ZPan 实例",
"settings.agentAccess.oauthReturn": "返回 URL",
@@ -1263,16 +1239,15 @@
"settings.agentAccess.oauthApprove": "批准访问",
"settings.agentAccess.oauthDeny": "拒绝",
"settings.agentAccess.oauthExpiredTitle": "OAuth 请求已过期",
"settings.agentAccess.oauthExpiredDescription": "请从 Restish 重新发起连接,生成新的授权请求。",
"settings.agentAccess.oauthExpiredDescription": "请重新发起连接,生成新的授权请求。",
"settings.agentAccess.oauthConsentFailed": "无法完成 OAuth 授权。",
"settings.agentAccess.oauthWorkspaceFailed": "无法切换工作空间。",
"settings.agentAccess.oauthGrantsSection": "委托 OAuth 授权",
"settings.agentAccess.oauthGrantsDescription": "管理连接到你工作空间的 Restish OAuth 授权。",
"settings.agentAccess.oauthGrantsDescription": "管理连接到你工作空间的应用 OAuth 授权。",
"settings.agentAccess.oauthNoGrants": "暂无委托 OAuth 授权",
"settings.agentAccess.oauthGrantsError": "无法加载委托 OAuth 授权。",
"settings.agentAccess.oauthGrantRevokeTitle": "撤销 OAuth 授权",
"settings.agentAccess.oauthGrantRevokeConfirm": "撤销 {{client}} 对 {{workspace}} 的访问?该工作空间的 Restish 会话将立即停止。",
"settings.agentAccess.oauthGrantRevokeSuccess": "OAuth 授权已撤销",
"settings.agentAccess.oauthGrantRevokeConfirm": "撤销 {{client}} 对 {{workspace}} 的访问?该工作空间的活动会话将立即停止。",
"settings.appearance.theme.description": "选择 ZPan 的外观,默认跟随系统。",
"settings.appearance.language.description": "界面显示语言。",
"settings.appearance.autoSaved": "修改即时生效。",
+7 -101
View File
@@ -12,7 +12,6 @@ import {
connectCloud,
continueCloudOrderPayment,
copyObject,
createAgentApiKey,
createAnnouncement,
createBackgroundJob,
createCloudBillingPortalSession,
@@ -81,7 +80,6 @@ import {
listActiveAnnouncements,
listAdminAnnouncements,
listAdminAuditLogs,
listAgentApiKeys,
listAgentOAuthGrants,
listAnnouncements,
listApiKeys,
@@ -124,7 +122,6 @@ import {
resetBrandingField,
restoreObject,
retryBackgroundJob,
revokeAgentApiKey,
revokeAgentOAuthGrant,
revokeIhostApiKey,
revokeOrgEntitlement,
@@ -133,7 +130,6 @@ import {
revokeSiteInvitation,
revokeUserEntitlement,
revokeWebDavAppPassword,
rotateAgentApiKey,
runDownloadTaskAction,
saveBranding,
saveEmailConfig,
@@ -3122,102 +3118,12 @@ describe('api', () => {
})
})
describe('Agent Access API keys', () => {
const sampleList = {
items: [
{
id: 'agent-key-1',
name: 'CI',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-07-29T00:00:00.000Z',
expiresAt: '2026-10-27T00:00:00.000Z',
lastUsedAt: null,
status: 'active',
},
],
total: 1,
page: 1,
pageSize: 50,
}
it('lists workspace Agent API keys through the Hono RPC route', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(sampleList))
const result = await listAgentApiKeys('org-1')
expect(result).toEqual(sampleList)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/workspaces/org-1/agent-api-keys')
expect(url).toContain('page=1')
expect(url).toContain('pageSize=50')
expect(init.method).toBe('GET')
})
it('creates a workspace Agent API key with explicit scopes and expiry', async () => {
const payload = { key: 'zpan_agent_secret', item: sampleList.items[0] }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201))
const result = await createAgentApiKey('org-1', {
name: 'CI',
scopes: ['objects:read'],
expiresAt: '2026-10-27T00:00:00.000Z',
})
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/workspaces/org-1/agent-api-keys')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual({
name: 'CI',
scopes: ['objects:read'],
expiresAt: '2026-10-27T00:00:00.000Z',
})
})
it('rotates a workspace Agent API key without sending the old secret', async () => {
const payload = { key: 'zpan_agent_rotated', item: { ...sampleList.items[0], id: 'agent-key-2' } }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201))
const result = await rotateAgentApiKey('org-1', 'agent-key-1', { name: 'CI rotated' })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/workspaces/org-1/agent-api-keys/agent-key-1/rotations')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual({ name: 'CI rotated' })
})
it('revokes a workspace Agent API key with DELETE', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await revokeAgentApiKey('org-1', 'agent-key-1')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/workspaces/org-1/agent-api-keys/agent-key-1')
expect(init.method).toBe('DELETE')
})
it('throws ApiError when Agent key creation fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(
createAgentApiKey('org-1', {
name: 'CI',
scopes: ['objects:read'],
expiresAt: '2026-10-27T00:00:00.000Z',
}),
).rejects.toThrow('Forbidden')
})
})
describe('Agent OAuth consent and grants', () => {
const sampleGrantList = {
items: [
{
id: 'grant-1',
clientId: 'zpan-agent',
clientId: 'dynamic-client',
clientName: 'ZPan Agent',
userId: 'user-1',
orgId: 'org-1',
@@ -3232,7 +3138,7 @@ describe('api', () => {
it('loads server-owned OAuth consent context with the raw OAuth query', async () => {
const payload = {
clientId: 'zpan-agent',
clientId: 'dynamic-client',
clientName: 'ZPan Agent',
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
@@ -3243,19 +3149,19 @@ describe('api', () => {
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await getAgentOAuthConsentContext('client_id=zpan-agent&scope=objects%3Aread')
const result = await getAgentOAuthConsentContext('client_id=dynamic-client&scope=objects%3Aread')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/agent-oauth-consent')
expect(url).toContain('oauthQuery=client_id%3Dzpan-agent%26scope%3Dobjects%253Aread')
expect(url).toContain('oauthQuery=client_id%3Ddynamic-client%26scope%3Dobjects%253Aread')
expect(init.method).toBe('GET')
})
it('submits full OAuth consent through the Hono RPC wrapper without sending scope overrides', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'http://127.0.0.1:8484/callback?code=abc' }))
const result = await submitAgentOAuthConsent({ accept: true, oauthQuery: 'client_id=zpan-agent' })
const result = await submitAgentOAuthConsent({ accept: true, oauthQuery: 'client_id=dynamic-client' })
expect(result).toEqual({ url: 'http://127.0.0.1:8484/callback?code=abc' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
@@ -3264,7 +3170,7 @@ describe('api', () => {
expect(init.credentials).toBe('include')
expect(JSON.parse(init.body as string)).toEqual({
accept: true,
oauthQuery: 'client_id=zpan-agent',
oauthQuery: 'client_id=dynamic-client',
})
})
@@ -3278,7 +3184,7 @@ describe('api', () => {
},
} as unknown as Response)
await expect(submitAgentOAuthConsent({ accept: false, oauthQuery: 'client_id=zpan-agent' })).rejects.toThrow(
await expect(submitAgentOAuthConsent({ accept: false, oauthQuery: 'client_id=dynamic-client' })).rejects.toThrow(
ApiError,
)
})
+1 -49
View File
@@ -1,11 +1,6 @@
import { type ApiKeyMetadata, ApiKeyTemplate } from '@shared/api-key-templates'
import type { OAuthProviderConfig } from '@shared/oauth-providers'
import type {
AgentApiKey,
AgentApiKeyCreated,
AgentApiKeyCreateInput,
AgentApiKeyList,
AgentApiKeyRotateInput,
AgentOAuthConsentContext,
AgentOAuthConsentResult,
AgentOAuthConsentSubmit,
@@ -107,7 +102,6 @@ import {
adminQuotas,
adminSiteInvitations,
adminTeams,
agentApiKeysApi,
agentOAuthGrantsApi,
announcementsApi,
authedSharesApi,
@@ -1096,49 +1090,7 @@ export function deleteIhostConfig() {
})
}
// Agent Access API keys
export type {
AgentApiKey,
AgentApiKeyCreated,
AgentApiKeyCreateInput,
AgentApiKeyList,
AgentApiKeyRotateInput,
AgentOAuthConsentContext,
AgentOAuthConsentResult,
AgentOAuthGrant,
AgentOAuthGrantList,
}
export function listAgentApiKeys(orgId: string, page = 1, pageSize = 50) {
return unwrap<AgentApiKeyList>(
agentApiKeysApi[':orgId']['agent-api-keys'].$get({
param: { orgId },
query: { page: String(page), pageSize: String(pageSize) },
}),
)
}
export function createAgentApiKey(orgId: string, input: AgentApiKeyCreateInput) {
return unwrap<AgentApiKeyCreated>(
agentApiKeysApi[':orgId']['agent-api-keys'].$post({ param: { orgId }, json: input }),
)
}
export function rotateAgentApiKey(orgId: string, keyId: string, input: AgentApiKeyRotateInput = {}) {
return unwrap<AgentApiKeyCreated>(
agentApiKeysApi[':orgId']['agent-api-keys'][':keyId'].rotations.$post({
param: { orgId, keyId },
json: input,
}),
)
}
export function revokeAgentApiKey(orgId: string, keyId: string) {
return agentApiKeysApi[':orgId']['agent-api-keys'][':keyId'].$delete({ param: { orgId, keyId } }).then((res) => {
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
})
}
export type { AgentOAuthConsentContext, AgentOAuthConsentResult, AgentOAuthGrant, AgentOAuthGrantList }
export function getAgentOAuthConsentContext(oauthQuery: string) {
return unwrap<AgentOAuthConsentContext>(agentOAuthGrantsApi['agent-oauth-consent'].$get({ query: { oauthQuery } }))
-2
View File
@@ -6,7 +6,6 @@ import type {
AdminSiteInvitationsRoute,
AdminStatsRoute,
AdminTeamsRoute,
AgentApiKeysRoute,
AgentOAuthGrantsRoute,
AnnouncementsRoute,
AuthedSharesRoute,
@@ -51,7 +50,6 @@ export const objects = hc<ObjectsRoute>('/api/objects', opts)
export const downloadTasksApi = hc<DownloadTasksRoute>('/api/downloads/tasks', opts)
export const downloaderSelfApi = hc<DownloaderSelfRoute>('/api/downloads/downloaders', opts)
export const trash = hc<TrashRoute>('/api/trash', opts)
export const agentApiKeysApi = hc<AgentApiKeysRoute>('/api/workspaces', opts)
export const agentOAuthGrantsApi = hc<AgentOAuthGrantsRoute>('/api', opts)
export const storages = hc<StoragesRoute>('/api/site/storages', opts)
export const storageUsageApi = hc<StorageUsageRoute>('/api/storage', opts)
@@ -1,10 +1,16 @@
import { createFileRoute } from '@tanstack/react-router'
import { OAuthProvidersSection } from '@/components/admin/oauth-providers-section'
import { RegisteredOAuthApplicationsSection } from '@/components/admin/registered-oauth-applications-section'
export const Route = createFileRoute('/_authenticated/admin/settings/oauth')({
component: AuthSettingsPage,
})
function AuthSettingsPage() {
return <OAuthProvidersSection />
return (
<div className="space-y-8">
<OAuthProvidersSection />
<RegisteredOAuthApplicationsSection />
</div>
)
}
@@ -117,8 +117,38 @@ const uploadDraft: CreateObjectResult = {
url: 'https://uploads.example.com/object-1',
expiresAt: '2026-01-01T00:15:00.000Z',
headers: { 'content-type': 'text/plain' },
offset: 0,
length: 5,
},
],
workflow: {
version: '1',
upload: {
method: 'PUT',
urlField: 'parts[].url',
headersField: 'parts[].headers',
fileOffsetField: 'parts[].offset',
contentLengthField: 'parts[].length',
etagHeader: 'ETag',
},
complete: {
operationId: 'completeObjectUpload',
method: 'POST',
path: '/api/objects/object-1/upload/completions',
partsBodyField: 'parts',
},
rePresign: {
operationId: 'presignObjectUploadParts',
method: 'POST',
path: '/api/objects/object-1/upload/parts',
partNumbersBodyField: 'partNumbers',
},
abort: {
operationId: 'abortObjectUpload',
method: 'DELETE',
path: '/api/objects/object-1/upload',
},
},
},
}
@@ -1,474 +0,0 @@
import type { AgentApiKey, AgentOAuthGrant } from '@shared/schemas'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { toast } from 'sonner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createAgentApiKey,
getAgentOAuthConsentContext,
listAgentApiKeys,
listAgentOAuthGrants,
revokeAgentApiKey,
revokeAgentOAuthGrant,
rotateAgentApiKey,
submitAgentOAuthConsent,
} from '@/lib/api'
import { setActive, useListOrganizations } from '@/lib/auth-client'
import { redirectExternal } from '@/lib/browser-navigation'
import { AgentAccessSettingsPage } from './agent-access'
import { SettingsLayout } from './route'
const state = vi.hoisted(() => ({
orgs: [
{ id: 'org-1', name: 'Personal' },
{ id: 'org-2', name: 'Team Alpha' },
],
keys: [] as AgentApiKey[],
grants: [] as AgentOAuthGrant[],
webdavEnabled: true,
}))
const translations: Record<string, string> = {
'settings.agentAccess.scope.objectsRead': 'Files: read objects',
'settings.agentAccess.scope.objectsCreate': 'Files: create objects',
'settings.agentAccess.scope.objectsUpdate': 'Files: update objects',
'settings.agentAccess.scope.objectsDelete': 'Files: delete objects',
'settings.agentAccess.scope.sharesRead': 'Shares: read shares',
'settings.agentAccess.scope.sharesCreate': 'Shares: create shares',
'settings.agentAccess.scope.sharesDelete': 'Shares: revoke shares',
'settings.agentAccess.scope.quotaRead': 'Quota: read workspace quota',
'settings.agentAccess.scope.storageUsageRead': 'Storage usage: read workspace usage',
'settings.agentAccess.managementRequired': 'Owner or admin access is required',
'settings.agentAccess.oauthConsentTitle': 'Authorize ZPan Agent',
'settings.agentAccess.oauthClient': 'Client',
'settings.agentAccess.oauthOrigin': 'ZPan instance',
'settings.agentAccess.oauthReturn': 'Return URL',
'settings.agentAccess.oauthLifetime': 'Grant lifetime',
'settings.agentAccess.oauthLifetimeValue': '30 days',
'settings.agentAccess.oauthScopesTitle': 'Requested scopes',
'settings.agentAccess.oauthApprove': 'Approve Access',
'settings.agentAccess.oauthDeny': 'Deny',
'settings.agentAccess.oauthExpiredTitle': 'OAuth request expired',
'settings.agentAccess.oauthGrantsSection': 'Delegated OAuth Grants',
'settings.agentAccess.oauthNoGrants': 'No delegated OAuth grants yet',
'settings.agentAccess.oauthGrantRevokeTitle': 'Revoke OAuth Grant',
'settings.agentAccess.oauthGrantRevokeSuccess': 'OAuth grant revoked',
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => translations[key] ?? key }),
}))
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
vi.mock('@tanstack/react-router', () => ({
Outlet: () => <div>outlet</div>,
createFileRoute: () => (options: unknown) => options,
}))
vi.mock('@/components/layout/page-header', () => ({
PageHeader: () => <div>page-header</div>,
}))
vi.mock('@/components/layout/page-tabs', () => ({
PageTabs: ({ items }: { items: Array<{ label: string }> }) => <div>{items.map((item) => item.label).join('|')}</div>,
}))
vi.mock('@/hooks/use-site-config', () => ({
useSiteConfig: () => ({
data: { services: { webdav: { enabled: state.webdavEnabled } } },
}),
}))
vi.mock('@/lib/auth-client', () => ({
useListOrganizations: vi.fn(),
setActive: vi.fn(),
}))
vi.mock('@/lib/browser-navigation', () => ({
redirectExternal: vi.fn(),
}))
vi.mock('@/lib/api', () => ({
createAgentApiKey: vi.fn(),
getAgentOAuthConsentContext: vi.fn(),
listAgentApiKeys: vi.fn(),
listAgentOAuthGrants: vi.fn(),
revokeAgentApiKey: vi.fn(),
revokeAgentOAuthGrant: vi.fn(),
rotateAgentApiKey: vi.fn(),
submitAgentOAuthConsent: vi.fn(),
}))
const queryClients: QueryClient[] = []
function renderWithQuery(ui: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
queryClient.setDefaultOptions({
queries: { retry: false, gcTime: 0 },
mutations: { retry: false, gcTime: 0 },
})
queryClients.push(queryClient)
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = vi.fn()
vi.mocked(useListOrganizations).mockReturnValue({ data: state.orgs } as never)
vi.mocked(listAgentApiKeys).mockImplementation(async (orgId: string) => ({
items: state.keys.filter((item) => item.orgId === orgId),
total: state.keys.filter((item) => item.orgId === orgId).length,
page: 1,
pageSize: 50,
}))
vi.mocked(listAgentOAuthGrants).mockImplementation(async () => ({ items: state.grants }))
vi.mocked(setActive).mockResolvedValue({ data: null, error: null } as never)
window.history.replaceState(null, '', '/settings/agent-access')
})
afterEach(() => {
cleanup()
for (const queryClient of queryClients.splice(0)) queryClient.clear()
vi.clearAllMocks()
vi.unstubAllGlobals()
state.keys = []
state.grants = []
state.webdavEnabled = true
})
describe('Agent Access settings page', () => {
it('loads the first workspace, fetches its keys, and keeps creation inside a dialog', async () => {
renderWithQuery(<AgentAccessSettingsPage />)
await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1'))
expect(await screen.findByText('settings.agentAccess.noKeys')).toBeTruthy()
expect(await screen.findByText('No delegated OAuth grants yet')).toBeTruthy()
expect(screen.queryByLabelText('settings.agentAccess.nameLabel')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' }))
expect(screen.getByLabelText('settings.agentAccess.nameLabel')).toBeTruthy()
expect(screen.getByLabelText('settings.agentAccess.expiryLabel')).toBeTruthy()
for (const label of [
'Files: read objects',
'Files: create objects',
'Files: update objects',
'Files: delete objects',
'Shares: read shares',
'Shares: create shares',
'Shares: revoke shares',
'Quota: read workspace quota',
'Storage usage: read workspace usage',
]) {
expect(screen.getByText(label)).toBeTruthy()
}
expect(screen.queryByText(/settings\.agentAccess\.scope\..*:/)).toBeNull()
})
it('creates a workspace Agent API key and reveals the secret once', async () => {
vi.mocked(createAgentApiKey).mockResolvedValue({
key: 'zpan_agent_secret',
item: {
id: 'agent-key-1',
name: 'CI key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-07-29T12:00:00.000Z',
expiresAt: '2026-10-27T23:59:59.000Z',
lastUsedAt: null,
status: 'active',
},
})
renderWithQuery(<AgentAccessSettingsPage />)
await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1'))
await screen.findByText('settings.agentAccess.noKeys')
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' }))
const dialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.createTitle' })
fireEvent.change(within(dialog).getByLabelText('settings.agentAccess.nameLabel'), {
target: { value: ' CI key ' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'settings.agentAccess.create' }))
await waitFor(() =>
expect(createAgentApiKey).toHaveBeenCalledWith(
'org-1',
expect.objectContaining({
name: 'CI key',
scopes: ['objects:read', 'shares:read', 'quota:read', 'storage-usage:read'],
expiresAt: expect.stringMatching(/T23:59:59\.000Z$/),
}),
),
)
expect(screen.getByText('zpan_agent_secret')).toBeTruthy()
expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.createSuccess')
})
it('rotates and revokes an existing workspace Agent API key', async () => {
state.keys = [
{
id: 'agent-key-1',
name: 'CI key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-07-29T12:00:00.000Z',
expiresAt: '2026-10-27T23:59:59.000Z',
lastUsedAt: null,
status: 'active',
},
]
vi.mocked(rotateAgentApiKey).mockResolvedValue({
key: 'zpan_agent_rotated',
item: {
...state.keys[0],
id: 'agent-key-2',
},
})
vi.mocked(revokeAgentApiKey).mockResolvedValue(undefined)
renderWithQuery(<AgentAccessSettingsPage />)
await screen.findByText('CI key')
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.rotate' }))
await waitFor(() => expect(rotateAgentApiKey).toHaveBeenCalledWith('org-1', 'agent-key-1'))
const revealedDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revealedTitle' })
expect(within(revealedDialog).getByText('zpan_agent_rotated')).toBeTruthy()
expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.rotateSuccess')
fireEvent.click(within(revealedDialog).getAllByRole('button', { name: 'common.close' })[1]!)
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'settings.agentAccess.revealedTitle' })).toBeNull())
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.revoke' }))
const revokeDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'settings.agentAccess.revoke' }))
await waitFor(() => expect(revokeAgentApiKey).toHaveBeenCalledWith('org-1', 'agent-key-1'))
expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.revokeSuccess')
})
it('surfaces rotate and revoke errors and lets the revoke dialog close from its close control', async () => {
state.keys = [
{
id: 'agent-key-1',
name: 'CI key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-07-29T12:00:00.000Z',
expiresAt: '2026-10-27T23:59:59.000Z',
lastUsedAt: null,
status: 'active',
},
]
vi.mocked(rotateAgentApiKey).mockRejectedValue(new Error('rotate failed'))
vi.mocked(revokeAgentApiKey).mockRejectedValue(new Error('revoke failed'))
renderWithQuery(<AgentAccessSettingsPage />)
await screen.findByText('CI key')
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.rotate' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('rotate failed'))
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.revoke' }))
const revokeDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'settings.agentAccess.revoke' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('revoke failed'))
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'common.close' }))
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })).toBeNull())
})
it('does not offer rotation for expired or revoked keys', async () => {
state.keys = [
{
id: 'expired-key',
name: 'Expired key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-02-01T00:00:00.000Z',
lastUsedAt: null,
status: 'expired',
},
{
id: 'revoked-key',
name: 'Revoked key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-12-01T00:00:00.000Z',
lastUsedAt: null,
status: 'revoked',
},
]
renderWithQuery(<AgentAccessSettingsPage />)
await screen.findByText('Expired key')
expect(screen.getByText('Revoked key')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'settings.agentAccess.rotate' })).toBeNull()
})
it('disables credential creation when the workspace management check fails', async () => {
vi.mocked(listAgentApiKeys).mockRejectedValue(new Error('Forbidden'))
renderWithQuery(<AgentAccessSettingsPage />)
expect(await screen.findByText('Owner or admin access is required')).toBeTruthy()
expect(screen.getByRole('button', { name: 'settings.agentAccess.create' }).hasAttribute('disabled')).toBe(true)
})
it('lists delegated OAuth grants and revokes them server-side', async () => {
state.grants = [
{
id: 'grant-1',
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
userId: 'user-1',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read', 'shares:create'],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: '2026-07-29T12:10:00.000Z',
status: 'active',
},
]
vi.mocked(revokeAgentOAuthGrant).mockResolvedValue(undefined)
renderWithQuery(<AgentAccessSettingsPage />)
expect(await screen.findByText('Delegated OAuth Grants')).toBeTruthy()
expect(await screen.findByText('ZPan Agent')).toBeTruthy()
expect(screen.getByText('Files: read objects')).toBeTruthy()
expect(screen.getByText('Shares: create shares')).toBeTruthy()
const revokeButtons = screen.getAllByRole('button', { name: 'settings.agentAccess.revoke' })
fireEvent.click(revokeButtons[revokeButtons.length - 1]!)
const dialog = await screen.findByRole('dialog', { name: 'Revoke OAuth Grant' })
fireEvent.click(within(dialog).getByRole('button', { name: 'settings.agentAccess.revoke' }))
await waitFor(() => expect(revokeAgentOAuthGrant).toHaveBeenCalledWith('grant-1'))
expect(toast.success).toHaveBeenCalledWith('OAuth grant revoked')
})
it('renders OAuth consent from server context and submits full approval', async () => {
window.history.replaceState(
null,
'',
'/settings/agent-access?client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback&response_type=code&scope=openid%20offline_access%20objects%3Aread%20quota%3Aread',
)
vi.mocked(getAgentOAuthConsentContext).mockResolvedValue({
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
scopes: ['objects:read', 'quota:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
})
vi.mocked(submitAgentOAuthConsent).mockResolvedValue({ url: 'http://127.0.0.1:8484/callback?code=abc' })
renderWithQuery(<AgentAccessSettingsPage />)
expect(await screen.findByRole('heading', { name: 'Authorize ZPan Agent' })).toBeTruthy()
expect(screen.getByText('https://zpan.example.test')).toBeTruthy()
expect(screen.getByText('http://127.0.0.1:8484/callback')).toBeTruthy()
expect(screen.getByText('Files: read objects')).toBeTruthy()
expect(screen.getByText('Quota: read workspace quota')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Approve Access' }))
await waitFor(() =>
expect(submitAgentOAuthConsent).toHaveBeenCalledWith({
accept: true,
oauthQuery: window.location.search.slice(1),
}),
)
expect(redirectExternal).toHaveBeenCalledWith('http://127.0.0.1:8484/callback?code=abc')
})
it('switches active workspace before OAuth consent and supports denial', async () => {
window.history.replaceState(
null,
'',
'/settings/agent-access?client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback&response_type=code&scope=objects%3Aread',
)
vi.mocked(getAgentOAuthConsentContext).mockResolvedValue({
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
scopes: ['objects:read'],
standardScopes: [],
redirectUri: 'http://127.0.0.1:8484/callback',
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
})
vi.mocked(submitAgentOAuthConsent).mockResolvedValue({ url: 'http://127.0.0.1:8484/callback?error=access_denied' })
renderWithQuery(<AgentAccessSettingsPage />)
await screen.findByRole('heading', { name: 'Authorize ZPan Agent' })
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(await screen.findByRole('option', { name: 'Team Alpha' }))
await waitFor(() => expect(setActive).toHaveBeenCalledWith({ organizationId: 'org-2' }))
fireEvent.click(screen.getByRole('button', { name: 'Deny' }))
await waitFor(() =>
expect(submitAgentOAuthConsent).toHaveBeenCalledWith({
accept: false,
oauthQuery: window.location.search.slice(1),
}),
)
expect(redirectExternal).toHaveBeenCalledWith('http://127.0.0.1:8484/callback?error=access_denied')
})
it('shows an expired OAuth request state when the consent context fails', async () => {
window.history.replaceState(
null,
'',
'/settings/agent-access?client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback',
)
vi.mocked(getAgentOAuthConsentContext).mockRejectedValue(new Error('expired'))
renderWithQuery(<AgentAccessSettingsPage />)
expect(await screen.findByRole('heading', { name: 'OAuth request expired' })).toBeTruthy()
})
})
describe('Settings layout tabs', () => {
it('includes the Agent Access tab alongside existing settings tabs', () => {
renderWithQuery(<SettingsLayout />)
expect(screen.getByText(/settings\.tabApiKeys\|settings\.tabAgentAccess/)).toBeTruthy()
})
it('keeps the Agent Access tab when WebDAV is disabled', () => {
state.webdavEnabled = false
renderWithQuery(<SettingsLayout />)
expect(screen.getByText(/settings\.tabApiKeys\|settings\.tabAgentAccess/)).toBeTruthy()
expect(screen.queryByText(/settings\.tabWebDav/)).toBeNull()
})
})

Some files were not shown because too many files have changed in this diff Show More