Skip to content

Commit 9a88494

Browse files
committed
Release v1.12.4
Origin-SHA: 3643ebdaab0e2e9139ac9f609e6f2b810e7c743d
1 parent 9e00a73 commit 9a88494

12 files changed

Lines changed: 203 additions & 184 deletions

File tree

.agents/rules.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ CLI (src/cli.ts) SDK (src/sdk.ts)
5858
| `src/types/api.ts` | TypeScript interfaces matching OpenSea API v2 response shapes. |
5959
| `src/types/index.ts` | Re-exports API types plus internal config types. |
6060

61+
### Auth Session Invariants
62+
63+
- Persisted tokens are a strict prerelease contract. Every entry requires a non-empty access token, refresh token, wallet address, ISO expiration, scopes array, and explicit `oauth` or `siwe` auth method. Do not add legacy-field fallbacks; incompatible stores should require a new login.
64+
- OAuth login requires a JWT access token with the top-level `wallet` claim. Never substitute the OIDC `sub` claim, which is an account identifier, or save a token under a placeholder address.
65+
- Normalize only `0x`-prefixed EVM addresses to lowercase. Preserve all other wallet addresses exactly because Solana base58 addresses are case-sensitive.
66+
- The SDK owns refresh-token rotation semantics. CLI callers should persist the required `OAuthToken.refreshToken` exactly as returned.
67+
6168
### API Types
6269

6370
API request/response types live in `src/types/api.ts` and are mostly thin re-exports of `components["schemas"]["..."]` from `@opensea/api-types`. **Never hand-roll types for OpenSea API v2 endpoints.** When adding a new endpoint:

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# @opensea/cli
22

3+
## 1.12.4
4+
5+
### Patch Changes
6+
7+
- b64a4d5: Require complete OAuth wallet sessions, retain refresh tokens during rotation, validate the CLI auth store, and preserve case-sensitive wallet addresses.
8+
- Updated dependencies [b64a4d5]
9+
- @opensea/sdk@11.4.3
10+
11+
## 1.12.3
12+
13+
### Patch Changes
14+
15+
- 3fba2be: Add `opensea drops eligibility <slug>` for checking the authenticated wallet's drop eligibility.
16+
- Updated dependencies [5966017]
17+
- @opensea/sdk@11.4.2
18+
319
## 1.12.2
420

521
### Patch Changes

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opensea/cli",
3-
"version": "1.12.2",
3+
"version": "1.12.4",
44
"type": "module",
55
"description": "OpenSea CLI - Query the OpenSea API from the command line or programmatically",
66
"main": "dist/index.js",
@@ -24,7 +24,7 @@
2424
},
2525
"dependencies": {
2626
"@opensea/api-types": "^0.8.0",
27-
"@opensea/sdk": "^11.4.1",
27+
"@opensea/sdk": "^11.4.3",
2828
"@opensea/wallet-adapters": "^0.3.2",
2929
"commander": "^14.0.3",
3030
"zod": "^4.3.6"

src/auth/store.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
22
import { homedir } from "node:os"
33
import { join } from "node:path"
4-
import { extractOpenSeaScopes } from "@opensea/sdk"
4+
import { z } from "zod"
55

66
/**
77
* Shape of a persisted auth token entry.
@@ -23,6 +23,24 @@ interface AuthStore {
2323
tokens: Record<string, StoredToken>
2424
}
2525

26+
const storedTokenSchema = z
27+
.object({
28+
accessToken: z.string().min(1),
29+
refreshToken: z.string().min(1),
30+
expiresAt: z.iso.datetime(),
31+
scopes: z.array(z.string().min(1)),
32+
address: z.string().min(1),
33+
authMethod: z.enum(["oauth", "siwe"]),
34+
})
35+
.strict()
36+
37+
const authStoreSchema = z
38+
.object({
39+
currentAddress: z.string().min(1).optional(),
40+
tokens: z.record(z.string(), storedTokenSchema),
41+
})
42+
.strict()
43+
2644
const AUTH_DIR = join(homedir(), ".opensea")
2745
const AUTH_FILE = join(AUTH_DIR, "auth.json")
2846

@@ -41,10 +59,21 @@ function readStore(): AuthStore {
4159
if (!existsSync(AUTH_FILE)) return { tokens: {} }
4260
try {
4361
const data = readFileSync(AUTH_FILE, "utf-8")
44-
return JSON.parse(data) as AuthStore
62+
const store = authStoreSchema.parse(JSON.parse(data))
63+
for (const [key, token] of Object.entries(store.tokens)) {
64+
if (key !== normalizeAddress(token.address)) {
65+
throw new Error(
66+
"Auth store token key does not match its wallet address",
67+
)
68+
}
69+
}
70+
if (store.currentAddress && !store.tokens[store.currentAddress]) {
71+
throw new Error("Auth store current address has no matching token")
72+
}
73+
return store
4574
} catch {
4675
console.warn(
47-
`Warning: ${AUTH_FILE} is corrupted. Starting with empty store.`,
76+
`Warning: ${AUTH_FILE} is corrupted or incompatible. Run \`opensea login\` to authenticate again.`,
4877
)
4978
return { tokens: {} }
5079
}
@@ -60,19 +89,20 @@ function writeStore(store: AuthStore): void {
6089
}
6190
}
6291

63-
function withDerivedScopes(token: StoredToken): StoredToken {
64-
if (token.scopes.length > 0) return token
65-
const scopes = extractOpenSeaScopes(token.accessToken)
66-
return scopes.length > 0 ? { ...token, scopes } : token
92+
function normalizeAddress(address: string): string {
93+
return address.slice(0, 2).toLowerCase() === "0x"
94+
? address.toLowerCase()
95+
: address
6796
}
6897

6998
/**
7099
* Save a token to the persistent store, keyed by wallet address.
71100
*/
72101
export function saveToken(token: StoredToken): void {
73102
const store = readStore()
74-
store.tokens[token.address.toLowerCase()] = token
75-
store.currentAddress = token.address.toLowerCase()
103+
const key = normalizeAddress(token.address)
104+
store.tokens[key] = storedTokenSchema.parse(token)
105+
store.currentAddress = key
76106
writeStore(store)
77107
}
78108

@@ -82,25 +112,23 @@ export function saveToken(token: StoredToken): void {
82112
export function loadCurrentToken(): StoredToken | undefined {
83113
const store = readStore()
84114
if (!store.currentAddress) return undefined
85-
const token = store.tokens[store.currentAddress]
86-
return token ? withDerivedScopes(token) : undefined
115+
return store.tokens[store.currentAddress]
87116
}
88117

89118
/**
90119
* Load a specific token by wallet address.
91120
*/
92121
export function loadToken(address: string): StoredToken | undefined {
93122
const store = readStore()
94-
const token = store.tokens[address.toLowerCase()]
95-
return token ? withDerivedScopes(token) : undefined
123+
return store.tokens[normalizeAddress(address)]
96124
}
97125

98126
/**
99127
* List all stored tokens.
100128
*/
101129
export function listTokens(): StoredToken[] {
102130
const store = readStore()
103-
return Object.values(store.tokens).map(withDerivedScopes)
131+
return Object.values(store.tokens)
104132
}
105133

106134
/**
@@ -109,7 +137,7 @@ export function listTokens(): StoredToken[] {
109137
*/
110138
export function removeToken(address: string): boolean {
111139
const store = readStore()
112-
const key = address.toLowerCase()
140+
const key = normalizeAddress(address)
113141
if (!store.tokens[key]) return false
114142
delete store.tokens[key]
115143
if (store.currentAddress === key) {

src/commands/auth.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -323,14 +323,6 @@ export function authCommand(
323323
console.error("No stored token to refresh")
324324
process.exit(1)
325325
}
326-
if (!token.refreshToken) {
327-
throw new Error("Stored auth token has no refresh token")
328-
}
329-
if (!token.authMethod) {
330-
throw new Error(
331-
"Stored auth token has no auth method. Run `opensea login` again.",
332-
)
333-
}
334326
const authBase = getAuthBaseUrl?.() ?? DEFAULT_AUTH_BASE_URL
335327

336328
if (token.authMethod === "oauth") {
@@ -339,10 +331,9 @@ export function authCommand(
339331
issuer: authBase,
340332
})
341333
const refreshed = await oauth.refresh(token.refreshToken)
342-
const refreshToken = refreshed.refreshToken || token.refreshToken
343334
saveToken({
344335
accessToken: refreshed.accessToken,
345-
refreshToken,
336+
refreshToken: refreshed.refreshToken,
346337
expiresAt: refreshed.expiresAt.toISOString(),
347338
scopes: refreshed.scopes,
348339
address: token.address,

src/commands/drops.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
Chain,
88
DropDeployRequest,
99
DropDeployResponse,
10+
DropEligibilityResponse,
1011
DropMintResponse,
1112
} from "../types/index.js"
1213

@@ -56,6 +57,18 @@ export function dropsCommand(
5657
await outputGet(client, getFormat(), `/api/v2/drops/${slug}`)
5758
})
5859

60+
cmd
61+
.command("eligibility")
62+
.description("Check drop eligibility for the authenticated wallet")
63+
.argument("<slug>", "Collection slug")
64+
.action(async (slug: string) => {
65+
const client = getClient()
66+
const result = await client.get<DropEligibilityResponse>(
67+
`/api/v2/drops/${slug}/eligibility`,
68+
)
69+
console.log(formatOutput(result, getFormat()))
70+
})
71+
5972
cmd
6073
.command("mint")
6174
.description("Build a mint transaction for a drop")

src/commands/login.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,17 @@ export function loginCommand(
8383
},
8484
})
8585

86-
const claims = safeDecodeClaims(token.accessToken)
87-
const address = extractWalletAddress(claims) ?? "unknown"
86+
const claims = decodeJwtPayload(token.accessToken)
87+
const address = extractWalletAddress(claims)
88+
if (!address) {
89+
throw new Error(
90+
"OAuth access token is missing the required wallet claim",
91+
)
92+
}
8893

8994
saveToken({
9095
accessToken: token.accessToken,
91-
refreshToken: token.refreshToken ?? "",
96+
refreshToken: token.refreshToken,
9297
expiresAt: token.expiresAt.toISOString(),
9398
scopes: token.scopes,
9499
address,
@@ -110,21 +115,6 @@ export function loginCommand(
110115
)
111116
}
112117

113-
/**
114-
* Decode token claims, tolerating opaque (non-JWT) access tokens. The
115-
* authorization server normally issues a JWT, but if it returns an opaque
116-
* token `decodeJwtPayload` throws — fall back to empty claims so
117-
* `extractWalletAddress` uses its `sub`/`unknown` path and the token is still
118-
* saved.
119-
*/
120-
function safeDecodeClaims(accessToken: string): Record<string, unknown> {
121-
try {
122-
return decodeJwtPayload(accessToken)
123-
} catch {
124-
return {}
125-
}
126-
}
127-
128118
async function runDeviceFlow(
129119
oauth: OpenSeaOAuth,
130120
scopes: string[],

src/types/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type DropStageResponse = Schemas["DropStageResponse"]
6464
export type DropPaginatedResponse = Schemas["DropPaginatedResponse"]
6565
export type DropMintRequest = Schemas["DropMintRequest"]
6666
export type DropMintResponse = Schemas["DropMintResponse"]
67+
export type DropEligibilityResponse = Schemas["DropEligibilityResponse"]
6768
export type DropDeployRequest = Schemas["DropDeployRequest"]
6869
export type DropDeployResponse = Schemas["DropDeployResponse"]
6970
export type DropDeployReceiptResponse = Schemas["DropDeployReceiptResponse"]

0 commit comments

Comments
 (0)