Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/nylas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ func main() {
rootCmd.AddCommand(contacts.NewContactsCmd())
rootCmd.AddCommand(dashboard.NewDashboardCmd())
rootCmd.AddCommand(oauthcmd.NewOAuthCmd())
// The dashboard commands renew OAuth-backed sessions through `nylas oauth`.
oauthcmd.WireDashboard()
rootCmd.AddCommand(setup.NewSetupCmd())
rootCmd.AddCommand(scheduler.NewSchedulerCmd())
rootCmd.AddCommand(admin.NewAdminCmd())
Expand Down
6 changes: 5 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ url := qb.BuildURL(baseURL)
- `llm.go` - LLM interface (AI providers)
- `config.go` - Config interface
- `webhook_server.go` - Webhook server interface
- `oauthas.go` - OAuthAuthServerClient (Nylas OAuth authorization server: discovery, PKCE code exchange, refresh, revoke)
- `lock.go` - CrossProcessLock (session locks shared by every CLI process on a machine)
- `mcp.go` - MCPCredentialSource (the API key or OAuth token `nylas mcp serve` sends)

3. **Adapters** (`internal/adapters/`)

Expand All @@ -192,7 +195,8 @@ url := qb.BuildURL(baseURL)
| `mcp/` | MCP proxy server for AI assistants |
| `config/` | Configuration validation |
| `oauth/` | OAuth callback server |
| `filelock/` | Cross-process advisory file lock (flock / LockFileEx) serialising OAuth token refresh |
| `oauthas/` | Client for the Nylas OAuth authorization server (PKCE, refresh, revoke) |
| `filelock/` | Cross-process advisory file lock (flock / LockFileEx): the OAuth and dashboard session locks and the encrypted file store's `.secrets.lock` |
| `utilities/` | Services (contacts, email, scheduling, timezone, webhook) |
| `browser/` | Browser automation |
| `tunnel/` | Cloudflare tunnel |
Expand Down
20 changes: 20 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ nylas oauth login --for mcp # Scopes + resource for `nylas mcp serve --auth
nylas oauth login --region eu # Sign up with a new organization in the EU
nylas oauth status # Show the stored session and decoded token claims
nylas oauth status --verify # Also confirm the token against /oauth/userinfo
nylas oauth status --json # Same facts as JSON (never includes a token)
nylas oauth token # Print a valid access token, refreshing if needed
nylas oauth logout # Revoke the session and clear stored tokens
```
Expand Down Expand Up @@ -181,6 +182,17 @@ server has already rotated the token it was sent.
scopes and expiry. The claims are **decoded, not verified** — the CLI does not
check the signature; only the resource server's answer is authoritative.

`nylas oauth status`, `login` and `logout` honour the global `--json`,
`--format yaml` and `--quiet` flags. `oauth status --json` prints `logged_in`,
`expired`, `server`, `issuer`, `client_id`, `resource`, `scopes`, `expires_at`,
`has_refresh_token` and the decoded `token_claims` (plus `userinfo` with
`--verify`); no token is ever included. `oauth status --quiet` prints nothing,
so the exit code alone says whether a session is stored. In device sign-in,
`oauth login` always shows the verification URL and code, since the login
cannot finish without them: on stderr with `--json`, `--format yaml` or
`--quiet`, so stdout carries only the report. `nylas oauth token` always prints
the bare token.

Default scopes are `openid`, `email`, `offline_access` and `dashboard.session`.
`offline_access` is what makes the server issue a refresh token; without it the
session ends when the access token expires (15 minutes by default).
Expand Down Expand Up @@ -276,6 +288,14 @@ the stored session is refused rather than sent to a server that did not issue
it; log in again, or restore the settings. `nylas dashboard logout` then clears
it locally without contacting the new server.

The session is also DPoP-bound to the Ed25519 key in `dashboard_dpop_key`. The
CLI never replaces a stored key that it cannot read or that is not valid,
because that would orphan every session bound to it; dashboard commands fail
instead. If the key is not valid, run `nylas dashboard logout`: it removes the
session and the key locally (the session cannot be revoked without the key),
and the next login creates a new key. `nylas oauth logout` in that state still
removes a dashboard session from OAuth, and reports that it was not revoked.

### SSO (Direct)

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/security/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Secrets are stored in the system keyring under service name `"nylas"`:
| `dashboard_user_token`, `dashboard_org_token` | `ports.KeyDashboardUserToken`, `ports.KeyDashboardOrgToken` | Dashboard session tokens |
| `dashboard_user_public_id`, `dashboard_org_public_id` | `ports.KeyDashboardUserPublicID`, `ports.KeyDashboardOrgPublicID` | Signed-in user and active organization |
| `dashboard_app_id`, `dashboard_app_region` | `ports.KeyDashboardAppID`, `ports.KeyDashboardAppRegion` | Active application (`nylas dashboard apps use`) |
| `dashboard_dpop_key` | `ports.KeyDashboardDPoPKey` | Ed25519 seed the dashboard session is DPoP-bound to |
| `dashboard_dpop_key` | `ports.KeyDashboardDPoPKey` | Ed25519 seed the dashboard session is DPoP-bound to. Created only when absent; a key that cannot be read or is not valid is reported, never replaced. `nylas dashboard logout` removes a key that is not valid, with its session |
| `dashboard_session_origin`, `dashboard_session_expires_at` | `ports.KeyDashboardSessionOrigin`, `ports.KeyDashboardSessionExpiresAt` | Set on a session exchanged from `nylas oauth login` |
| `dashboard_session_server` | `ports.KeyDashboardSessionServer` | Servers the dashboard session was issued for; it is never sent elsewhere |
| `oauth_access_token`, `oauth_refresh_token` | `ports.KeyOAuthAccessToken`, `ports.KeyOAuthRefreshToken` | `nylas oauth login` session |
Expand Down
285 changes: 285 additions & 0 deletions internal/adapters/dashboard/account_client_domain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
package dashboard

import (
"context"
"net/http"
"testing"

"github.com/nylas/cli/internal/domain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAccountClientDomainOperations(t *testing.T) {
t.Parallel()

tests := []struct {
name string
handler func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, body map[string]any)
run func(t *testing.T, client *AccountClient)
}{
{
name: "list domains",
handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, _ map[string]any) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/orgs/inbox/domains", r.URL.Path)
assert.Equal(t, "25", r.URL.Query().Get("limit"))
assert.Equal(t, "cursor-1", r.URL.Query().Get("pageToken"))
assert.Equal(t, "Bearer user-token", r.Header.Get("Authorization"))
assert.Equal(t, "org-token", r.Header.Get("X-Nylas-Org"))
assert.NotEmpty(t, r.Header.Get("DPoP"))

writeDashboardEnvelopeWithCursor(t, w, []map[string]any{
{
"id": "dom_1",
"name": "Example",
"domainAddress": "example.com",
"organizationId": "org_1",
"region": "us",
"branded": true,
"verifiedOwnership": true,
},
}, "cursor-2")
},
run: func(t *testing.T, client *AccountClient) {
page, err := client.ListDomains(context.Background(), 25, "cursor-1", "user-token", "org-token")
require.NoError(t, err)
require.Len(t, page.Domains, 1)
assert.Equal(t, "dom_1", page.Domains[0].ID)
assert.Equal(t, "example.com", page.Domains[0].DomainAddress)
assert.True(t, page.Domains[0].VerifiedOwnership)
assert.Equal(t, "cursor-2", page.NextCursor)
},
},
{
name: "check availability",
handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, _ map[string]any) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/orgs/inbox/domains/availability", r.URL.Path)
assert.Equal(t, "example.com", r.URL.Query().Get("domainAddress"))

writeDashboardEnvelope(t, w, map[string]any{
"domainAddress": "example.com",
"available": true,
"conflictsWith": nil,
})
},
run: func(t *testing.T, client *AccountClient) {
result, err := client.CheckDomainAvailability(context.Background(), "example.com", "user-token", "org-token")
require.NoError(t, err)
assert.True(t, result.Available)
assert.Nil(t, result.ConflictsWith)
},
},
{
name: "create domain",
handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, body map[string]any) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/orgs/inbox/domains", r.URL.Path)
assert.Equal(t, "Example", body["name"])
assert.Equal(t, "example.com", body["domainAddress"])
assert.Equal(t, "eu", body["region"])

writeDashboardEnvelope(t, w, map[string]any{
"id": "dom_new",
"name": "Example",
"domainAddress": "example.com",
"region": "eu",
})
},
run: func(t *testing.T, client *AccountClient) {
created, err := client.CreateDomain(context.Background(), domain.DashboardCreateInboxDomainInput{
Name: "Example",
DomainAddress: "example.com",
Region: "eu",
}, "user-token", "org-token")
require.NoError(t, err)
assert.Equal(t, "dom_new", created.ID)
},
},
{
name: "update domain",
handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, body map[string]any) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/orgs/inbox/domains/dom_1", r.URL.Path)
assert.Equal(t, "us", r.URL.Query().Get("region"))
assert.Equal(t, "Renamed", body["name"])

writeDashboardEnvelope(t, w, map[string]any{
"id": "dom_1",
"name": "Renamed",
"domainAddress": "example.com",
"region": "us",
})
},
run: func(t *testing.T, client *AccountClient) {
updated, err := client.UpdateDomain(context.Background(), "dom_1", "us", domain.DashboardUpdateInboxDomainInput{Name: "Renamed"}, "user-token", "org-token")
require.NoError(t, err)
assert.Equal(t, "Renamed", updated.Name)
},
},
{
name: "get domain info",
handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, _ map[string]any) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/orgs/inbox/domains/dom_1/info", r.URL.Path)
assert.Equal(t, "us", r.URL.Query().Get("region"))
assert.Equal(t, "mx", r.URL.Query().Get("type"))

writeDashboardEnvelope(t, w, map[string]any{
"domainId": "dom_1",
"status": "pending",
"message": "Configure MX",
"attempt": map[string]any{
"type": "mx",
"options": map[string]any{
"host": "example.com",
"type": "MX",
"value": "10 inbound.nylas.com",
},
},
})
},
run: func(t *testing.T, client *AccountClient) {
info, err := client.GetDomainInfo(context.Background(), "dom_1", "us", "mx", "user-token", "org-token")
require.NoError(t, err)
assert.Equal(t, "pending", info.Status)
require.NotNil(t, info.Attempt)
assert.Equal(t, "MX", info.Attempt.Options.Type)
},
},
{
name: "verify domain",
handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, body map[string]any) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/orgs/inbox/domains/dom_1/verify", r.URL.Path)
assert.Equal(t, "eu", r.URL.Query().Get("region"))
assert.Equal(t, "ownership", body["type"])

writeDashboardEnvelope(t, w, map[string]any{
"domainId": "dom_1",
"status": "done",
"message": "Verified",
})
},
run: func(t *testing.T, client *AccountClient) {
result, err := client.VerifyDomain(context.Background(), "dom_1", "eu", domain.DashboardVerifyInboxDomainInput{Type: "ownership"}, "user-token", "org-token")
require.NoError(t, err)
assert.Equal(t, "done", result.Status)
},
},
{
name: "delete domain",
handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, _ map[string]any) {
assert.Equal(t, http.MethodDelete, r.Method)
assert.Equal(t, "/orgs/inbox/domains/dom_1", r.URL.Path)
assert.Equal(t, "us", r.URL.Query().Get("region"))

writeDashboardEnvelope(t, w, map[string]any{"success": true})
},
run: func(t *testing.T, client *AccountClient) {
deleted, err := client.DeleteDomain(context.Background(), "dom_1", "us", "user-token", "org-token")
require.NoError(t, err)
assert.True(t, deleted)
},
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

server := newAccountClientTestServer(t, tt.handler)
defer server.Close()

client := &AccountClient{
baseURL: server.URL,
httpClient: server.Client(),
dpop: &mockDPoP{proof: "test-proof"},
}

tt.run(t, client)
})
}
}

func TestDecodeDomainPageFallbackShapes(t *testing.T) {
t.Parallel()

tests := []struct {
name string
raw rawResponse
wantIDs []string
wantCursor string
wantErr string
}{
{
name: "object fallback with snake cursor",
raw: rawResponse{
Data: []byte(`{"domains":[{"id":"dom_1","domainAddress":"example.com"}],"next_cursor":"cursor-2"}`),
},
wantIDs: []string{"dom_1"},
wantCursor: "cursor-2",
},
{
name: "raw response cursor wins over object cursor",
raw: rawResponse{
Data: []byte(`{"domains":[{"id":"dom_1","domainAddress":"example.com"}],"nextCursor":"payload-cursor"}`),
NextCursor: "envelope-cursor",
},
wantIDs: []string{"dom_1"},
wantCursor: "envelope-cursor",
},
{
name: "empty object page with cursor",
raw: rawResponse{
Data: []byte(`{"domains":[],"pageToken":"cursor-3"}`),
},
wantCursor: "cursor-3",
},
{
name: "object fallback requires domains field",
raw: rawResponse{
Data: []byte(`{"nextCursor":"cursor-4"}`),
},
wantErr: "missing domains",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

page, err := decodeDomainPage(tt.raw)

if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantCursor, page.NextCursor)
require.Len(t, page.Domains, len(tt.wantIDs))
for i, id := range tt.wantIDs {
assert.Equal(t, id, page.Domains[i].ID)
}
})
}
}

func TestUnwrapRawResponseExtractsNestedCursor(t *testing.T) {
t.Parallel()

raw, err := unwrapRawResponse([]byte(`{
"request_id":"req",
"success":true,
"data":[],
"pagination":{"next_cursor":"cursor-2"}
}`))

require.NoError(t, err)
assert.JSONEq(t, `[]`, string(raw.Data))
assert.Equal(t, "cursor-2", raw.NextCursor)
}
Loading
Loading