mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
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:
@@ -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/*
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+3578
-2000
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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`);
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+119
-116
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)`)
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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' }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -59,7 +59,7 @@ export type AuthPrincipal =
|
||||
orgId: string
|
||||
userId: string
|
||||
scopes: readonly AuthorizationScope[]
|
||||
authMethod: 'bearer'
|
||||
authMethod: 'bearer' | 'dpop'
|
||||
}
|
||||
| {
|
||||
kind: 'downloader'
|
||||
|
||||
+124
-104
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'] })
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 } }))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user