From 223fe6f9a2c96c79e2a5f2f0af3995a5e18d0d35 Mon Sep 17 00:00:00 2001 From: dmahacker Date: Wed, 7 Oct 2026 23:46:16 +0800 Subject: [PATCH 1/3] :sparkles: Feature: add OAuth voucher redemption client method --- README.md | 12 ++++++++++++ README_ZH.md | 12 ++++++++++++ src/client.ts | 7 ++++++- src/types.ts | 8 ++++++++ tests/redeem.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/redeem.test.ts diff --git a/README.md b/README.md index f29ecea..b471066 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,18 @@ const client = new PicGoCloudClient({ API requests use native fetch; you can inject an implementation through the `fetch` option. File transfers use native XMLHttpRequest for upload progress and do not use the custom fetch implementation. Importing the module does not access the DOM. Uploads require the browser's Blob/File, XMLHttpRequest, Web Crypto, and AbortSignal.any APIs. Use an HTTPS or localhost secure context. +## Redeeming membership vouchers (OAuth) + +```ts +const client = new PicGoCloudClient({ token: async () => getOAuthAccessToken() }) +const { entitlementId } = await client.redeem({ code: voucherCode }) +// Optional cancellation: client.redeem({ code: voucherCode }, { signal }) +``` + +Requires an OAuth access token with `entitlements:redeem` and a Worker that supports `POST /api/redeem`. Submission confirms redemption for the token's user, not the browser's logged-in account. No `userId` parameter is accepted. Keep client secrets and refresh tokens on the partner backend; the browser token provider should return only short-lived access tokens. + +Existing grants must obtain explicit consent for this new scope. Refreshing an old token does not add permissions. Active membership blocks redemption without consuming the voucher; a retry by the same user after successful redemption returns the original entitlement. API failures throw `PicGoCloudError` with `status` and `code` (for example `REDEMPTION_ACTIVE_MEMBERSHIP`, HTTP 409). The SDK does not automatically retry redemption. This method is new and is not included in the previously published `0.1.0` package. + ## Uploading files ```ts diff --git a/README_ZH.md b/README_ZH.md index 66b58c9..19f251f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -39,6 +39,18 @@ const client = new PicGoCloudClient({ API 使用原生 fetch,可通过 `fetch` 选项注入自定义实现。上传字节使用原生 XMLHttpRequest 以提供浏览器上传进度,不经过自定义 fetch。模块导入本身不访问 DOM;实际上传需要浏览器的 Blob/File、XMLHttpRequest、Web Crypto 和 AbortSignal.any,建议在 HTTPS 或 localhost 安全上下文使用。 +## 兑换会员(OAuth) + +```ts +const client = new PicGoCloudClient({ token: async () => getOAuthAccessToken() }) +const { entitlementId } = await client.redeem({ code: voucherCode }) +// 可选取消:client.redeem({ code: voucherCode }, { signal }) +``` + +需要具备 `entitlements:redeem` 的 OAuth access token,以及支持 `POST /api/redeem` 的 Worker。提交即确认兑换,权益只发给 token 对应的用户,不依赖浏览器当前登录账号,不接受 `userId`。client secret 和 refresh token 必须留在合作方后端,浏览器 token provider 只取得短期 access token。 + +老授权需要用户明确同意新增权限,刷新旧 token 不会自动获得权限。有生效会员时拒绝兑换且不消耗兑换码;同一用户重试已成功的兑换返回原 entitlement。失败抛出带 `status`、`code` 的 `PicGoCloudError`,例如 HTTP 409 / `REDEMPTION_ACTIVE_MEMBERSHIP`。SDK 不自动重试兑换。此方法是新增功能,已发布的 `0.1.0` 包尚不包含它。 + ## 上传 ```ts diff --git a/src/client.ts b/src/client.ts index 1d76932..14a3def 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,7 @@ import { HttpClient } from './http.js' import type { HttpClientConfig } from './http.js' import { MediaService } from './media.js' -import type { RequestOptions, WhoAmI, MediaItem } from './types.js' +import type { RequestOptions, WhoAmI, MediaItem, RedeemInput, RedeemResult } from './types.js' import { UploadTask } from './upload/task.js' import type { UploadConfig, UploadOptions } from './upload/types.js' @@ -20,6 +20,11 @@ export class PicGoCloudClient { return this.http.request('/api/whoami', options) } + /** Redeem for the OAuth token's user. Requires entitlements:redeem; submission confirms redemption. */ + redeem(input: RedeemInput, options?: RequestOptions): Promise { + return this.http.request('/api/redeem', { ...options, method: 'POST', body: input }) + } + createUpload(file: Blob, options?: UploadOptions): UploadTask { return new UploadTask(this.http, file, options, this.options) } diff --git a/src/types.ts b/src/types.ts index 8457141..e9e8281 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,14 @@ export interface RequestOptions { signal?: AbortSignal } +export interface RedeemInput { + code: string +} + +export interface RedeemResult { + entitlementId: string +} + export interface WhoAmI { /** Available on current Workers; optional for compatibility with older deployments. */ userId?: string diff --git a/tests/redeem.test.ts b/tests/redeem.test.ts new file mode 100644 index 0000000..675748f --- /dev/null +++ b/tests/redeem.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { PicGoCloudClient } from '../src/index.js' + +describe('PicGoCloudClient.redeem', () => { + it('posts the voucher with the provider token, omits cookies and unwraps the entitlement', async () => { + const client = new PicGoCloudClient({ + baseUrl: 'https://api.example.test', + token: async () => 'pgoa_access', + fetch: async (url, init) => { + expect(url).toBe('https://api.example.test/api/redeem') + expect(init?.method).toBe('POST') + expect(init?.credentials).toBe('omit') + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer pgoa_access') + expect(new Headers(init?.headers).get('Content-Type')).toBe('application/json') + expect(JSON.parse(String(init?.body))).toEqual({ code: 'a'.repeat(43) }) + return Response.json({ success: true, data: { entitlementId: '42' } }) + }, + }) + expect(await client.redeem({ code: 'a'.repeat(43) })).toEqual({ entitlementId: '42' }) + }) + + it('preserves redemption error status and code', async () => { + const client = new PicGoCloudClient({ + token: 'pgoa_access', + fetch: async () => Response.json({ success: false, code: 'REDEMPTION_ACTIVE_MEMBERSHIP', message: 'Active membership' }, { status: 409 }), + }) + await expect(client.redeem({ code: 'a'.repeat(43) })).rejects.toMatchObject({ + kind: 'api', status: 409, code: 'REDEMPTION_ACTIVE_MEMBERSHIP', + }) + }) + + it('honors an aborted request without calling the transport', async () => { + const client = new PicGoCloudClient({ token: 'token', fetch: async () => { throw new Error('must not send') } }) + await expect(client.redeem({ code: 'a'.repeat(43) }, { signal: AbortSignal.abort() })).rejects.toMatchObject({ kind: 'aborted' }) + }) +}) From 9c429312be6acc5c9a7e089e648f180b0ee52a79 Mon Sep 17 00:00:00 2001 From: dmahacker Date: Sat, 10 Oct 2026 09:42:42 +0800 Subject: [PATCH 2/3] :sparkles: Feature: add storage usage, entitlements and upload task status methods --- README.md | 9 +++++++++ README_ZH.md | 9 +++++++++ src/client.ts | 21 ++++++++++++++++++++- src/types.ts | 40 ++++++++++++++++++++++++++++++++++++++++ tests/account.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/account.test.ts diff --git a/README.md b/README.md index b471066..03a348b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,15 @@ Requires an OAuth access token with `entitlements:redeem` and a Worker that supp Existing grants must obtain explicit consent for this new scope. Refreshing an old token does not add permissions. Active membership blocks redemption without consuming the voucher; a retry by the same user after successful redemption returns the original entitlement. API failures throw `PicGoCloudError` with `status` and `code` (for example `REDEMPTION_ACTIVE_MEMBERSHIP`, HTTP 409). The SDK does not automatically retry redemption. This method is new and is not included in the previously published `0.1.0` package. +## Storage, membership and upload task status (OAuth) + +```ts +const usage = await client.getStorageUsage() // storage:usage:read +const membership = await client.getEntitlements() // entitlements:read +const task = await client.getUploadStatus({ objectKey, uploadId }) // files:upload; uploadId only for multipart +``` + +`getStorageUsage()` returns used/limit/remaining for bytes and media count plus the upload size and extension limits. `getEntitlements()` returns the plan, lifecycle phase, phase end time and an informational `redemption.eligible` hint; `redeem()` still decides on the server. `getUploadStatus()` reports whether the object of an upload started by the same user and OAuth client has arrived (`awaiting_object`, `object_uploaded` or `aborted`). These methods are new and not included in `0.1.0`. ## Uploading files ```ts diff --git a/README_ZH.md b/README_ZH.md index 19f251f..9327d9c 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -51,6 +51,15 @@ const { entitlementId } = await client.redeem({ code: voucherCode }) 老授权需要用户明确同意新增权限,刷新旧 token 不会自动获得权限。有生效会员时拒绝兑换且不消耗兑换码;同一用户重试已成功的兑换返回原 entitlement。失败抛出带 `status`、`code` 的 `PicGoCloudError`,例如 HTTP 409 / `REDEMPTION_ACTIVE_MEMBERSHIP`。SDK 不自动重试兑换。此方法是新增功能,已发布的 `0.1.0` 包尚不包含它。 +## 容量、会员与上传任务状态(OAuth) + +```ts +const usage = await client.getStorageUsage() // storage:usage:read +const membership = await client.getEntitlements() // entitlements:read +const task = await client.getUploadStatus({ objectKey, uploadId }) // files:upload;仅分片上传需要 uploadId +``` + +`getStorageUsage()` 返回容量字节数和文件数的已用/上限/剩余,以及单文件大小和扩展名限制。`getEntitlements()` 返回套餐、生命周期阶段、阶段结束时间,以及仅供参考的 `redemption.eligible`;能否兑换仍以 `redeem()` 的服务端结果为准。`getUploadStatus()` 报告同一用户、同一 OAuth 客户端发起的上传对象是否已到达(`awaiting_object`、`object_uploaded` 或 `aborted`)。这些方法是新增功能,`0.1.0` 尚不包含。 ## 上传 ```ts diff --git a/src/client.ts b/src/client.ts index 14a3def..6bc991a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,9 @@ import { HttpClient } from './http.js' import type { HttpClientConfig } from './http.js' import { MediaService } from './media.js' -import type { RequestOptions, WhoAmI, MediaItem, RedeemInput, RedeemResult } from './types.js' +import type { + RequestOptions, WhoAmI, MediaItem, RedeemInput, RedeemResult, StorageUsage, Entitlements, UploadTaskQuery, UploadTaskStatus, +} from './types.js' import { UploadTask } from './upload/task.js' import type { UploadConfig, UploadOptions } from './upload/types.js' @@ -25,6 +27,23 @@ export class PicGoCloudClient { return this.http.request('/api/redeem', { ...options, method: 'POST', body: input }) } + /** Requires storage:usage:read. */ + getStorageUsage(options?: RequestOptions): Promise { + return this.http.request('/api/storage/usage', options) + } + + /** Requires entitlements:read. */ + getEntitlements(options?: RequestOptions): Promise { + return this.http.request('/api/entitlements', options) + } + + /** Server-side upload task state for an upload started by the same user and OAuth client. Requires files:upload. */ + getUploadStatus(query: UploadTaskQuery, options?: RequestOptions): Promise { + const params = new URLSearchParams({ objectKey: query.objectKey }) + if (query.uploadId) params.set('uploadId', query.uploadId) + return this.http.request(`/api/upload/status?${params.toString()}`, options) + } + createUpload(file: Blob, options?: UploadOptions): UploadTask { return new UploadTask(this.http, file, options, this.options) } diff --git a/src/types.ts b/src/types.ts index e9e8281..ddfe2ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,46 @@ export interface RedeemResult { entitlementId: string } +export interface UsageCounter { + used: number + limit: number + remaining: number +} + +export interface StorageUsage { + /** Bytes. */ + storage: UsageCounter + mediaCount: UsageCounter + upload: { maxFileBytes: number; allowedExtensions: string[] } +} + +export type LifecyclePhase = 'active' | 'grace' | 'frozen' | 'pending_cleanup' + +export interface Entitlements { + plan: string + effectiveQuotaPlan: string + phase: LifecyclePhase + /** ISO timestamp, or null when the phase has no end. */ + currentPhaseEndsAt: string | null + /** Informational; redeem() rechecks on the server. code is an API error code when not eligible. */ + redemption: { eligible: boolean; code: string | null } +} + +export interface UploadTaskQuery { + objectKey: string + /** Required for multipart uploads. */ + uploadId?: string +} + +export interface UploadTaskStatus { + objectKey: string + uploadId: string | null + kind: 'single' | 'multipart' + state: 'awaiting_object' | 'object_uploaded' | 'aborted' + sizeBytes: number | null + expiresAt: string +} + export interface WhoAmI { /** Available on current Workers; optional for compatibility with older deployments. */ userId?: string diff --git a/tests/account.test.ts b/tests/account.test.ts new file mode 100644 index 0000000..5a77898 --- /dev/null +++ b/tests/account.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { PicGoCloudClient } from '../src/index.js' + +function clientReturning(data: unknown, seen: string[]) { + return new PicGoCloudClient({ + baseUrl: 'https://api.example.test', + token: 'pgoa_access', + fetch: async (url, init) => { + seen.push(`${init?.method ?? 'GET'} ${String(url)}`) + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer pgoa_access') + return Response.json({ success: true, data }) + }, + }) +} + +describe('partner account reads', () => { + it('reads storage usage and entitlements', async () => { + const seen: string[] = [] + const usage = { storage: { used: 1, limit: 10, remaining: 9 }, mediaCount: { used: 1, limit: 5, remaining: 4 }, upload: { maxFileBytes: 5, allowedExtensions: ['png'] } } + expect(await clientReturning(usage, seen).getStorageUsage()).toEqual(usage) + const entitlements = { plan: 'pro', effectiveQuotaPlan: 'pro', phase: 'active', currentPhaseEndsAt: null, redemption: { eligible: false, code: 'REDEMPTION_ACTIVE_MEMBERSHIP' } } + expect(await clientReturning(entitlements, seen).getEntitlements()).toEqual(entitlements) + expect(seen).toEqual(['GET https://api.example.test/api/storage/usage', 'GET https://api.example.test/api/entitlements']) + }) + + it('encodes the upload task reference and omits uploadId for single uploads', async () => { + const seen: string[] = [] + const status = { objectKey: 'u/a b.png', uploadId: null, kind: 'single', state: 'awaiting_object', sizeBytes: null, expiresAt: '2026-10-17T00:00:00.000Z' } + expect(await clientReturning(status, seen).getUploadStatus({ objectKey: 'u/a b.png' })).toEqual(status) + await clientReturning(status, seen).getUploadStatus({ objectKey: 'u/x', uploadId: 'up&1' }) + expect(seen).toEqual([ + 'GET https://api.example.test/api/upload/status?objectKey=u%2Fa+b.png', + 'GET https://api.example.test/api/upload/status?objectKey=u%2Fx&uploadId=up%261', + ]) + }) +}) From dcb1793f8873fe25c36eb7ce27509fb5f66e4aa5 Mon Sep 17 00:00:00 2001 From: dmahacker Date: Sat, 10 Oct 2026 10:13:15 +0800 Subject: [PATCH 3/3] :tada: Release: v0.2.0-beta.0 --- package.json | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 280f7ad..912ce5a 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,34 @@ { "name": "@picgo/cloud-sdk", - "version": "0.1.0", + "version": "0.2.0-beta.0", "description": "PicGo Cloud uploads and media management for modern browsers", "type": "module", "license": "MIT", - "repository": { "type": "git", "url": "git+https://github.com/PicGo/picgo-cloud-sdk.git" }, - "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" }, + "repository": { + "type": "git", + "url": "git+https://github.com/PicGo/picgo-cloud-sdk.git" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, "sideEffects": false, - "files": ["dist", "README.md", "README_ZH.md", "CHANGELOG.md", "LICENSE"], + "files": [ + "dist", + "README.md", + "README_ZH.md", + "CHANGELOG.md", + "LICENSE" + ], "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, "scripts": { "dev": "pnpm build && node --env-file-if-exists=.env scripts/dev.mjs", "build": "rolldown -c && tsc -p tsconfig.build.json", @@ -28,11 +45,17 @@ "prepare": "husky" }, "config": { - "commitizen": { "path": "./node_modules/cz-customizable" }, - "cz-customizable": { "config": "./node_modules/@picgo/bump-version/.cz-config.cjs" } + "commitizen": { + "path": "./node_modules/cz-customizable" + }, + "cz-customizable": { + "config": "./node_modules/@picgo/bump-version/.cz-config.cjs" + } }, "commitlint": { - "extends": ["./node_modules/@picgo/bump-version/commitlint-picgo/index.cjs"] + "extends": [ + "./node_modules/@picgo/bump-version/commitlint-picgo/index.cjs" + ] }, "packageManager": "pnpm@11.7.0", "devDependencies": {