Publish the unified MIT-licensed Stuffbox SDK source
Hop-State: A_06FPAF0M83E7T0JJ7EP2WQ0 Hop-Proposal: R_06FPAF04S0D4G8TA8TNK3C0 Hop-Task: T_06FPADTV2YD4PD5GMTK3150 Hop-Attempt: AT_06FPADTV2ZYKEY8GH3JTK98
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.tgz
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for helping improve the Stuffbox integration SDK.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Use Node.js 22 or newer and pnpm 10:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
corepack enable
|
||||||
|
pnpm install
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep changes inside the public integration surface: the framework-neutral client, shared API types, PKCE helpers, typed errors, React upload helpers, protocol documentation, and integration examples. The proprietary Stuffbox service is developed separately and is not part of this repository.
|
||||||
|
|
||||||
|
Add or update tests for behavior changes. Do not commit generated `dist` files, package tarballs, credentials, access tokens, signed upload URLs, or user media.
|
||||||
|
|
||||||
|
By contributing, you agree that your contribution is licensed under this repository's MIT License.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Gnosys Labs
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# `@stuffbox/sdk`
|
||||||
|
|
||||||
|
The official TypeScript SDK for connecting applications to Stuffbox, a user-owned media library. The package includes a framework-neutral client, shared API types, PKCE helpers, typed errors, and optional React upload helpers.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install @stuffbox/sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
The core entry point has no runtime dependencies. If you use `@stuffbox/sdk/react`, install React 18.2 or React 19 in your application.
|
||||||
|
|
||||||
|
## Connect a user
|
||||||
|
|
||||||
|
Run the connection flow on your server. Access and refresh tokens must not be embedded in browser JavaScript.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
StuffboxClient,
|
||||||
|
createPkcePair,
|
||||||
|
generateState,
|
||||||
|
} from "@stuffbox/sdk";
|
||||||
|
|
||||||
|
const stuffbox = new StuffboxClient({ baseUrl: "https://stuffbox.xyz" });
|
||||||
|
const pkce = await createPkcePair();
|
||||||
|
const state = generateState();
|
||||||
|
|
||||||
|
const connection = await stuffbox.createConnectionRequest({
|
||||||
|
selfHosted: true,
|
||||||
|
callbackUrl: "https://yourapp.example/api/stuffbox/callback",
|
||||||
|
codeChallenge: pkce.challenge,
|
||||||
|
scopes: ["assets:read"],
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist all four values before redirecting the user's browser.
|
||||||
|
await savePendingConnection({
|
||||||
|
clientId: connection.clientId,
|
||||||
|
callbackUrl: connection.callbackUrl,
|
||||||
|
codeVerifier: pkce.verifier,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(connection.authorizationUrl);
|
||||||
|
```
|
||||||
|
|
||||||
|
After the exact callback, compare the returned state with the persisted value and exchange the one-time code:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pending = await loadPendingConnection();
|
||||||
|
|
||||||
|
const tokens = await stuffbox.exchangeAuthorizationCode({
|
||||||
|
clientId: pending.clientId,
|
||||||
|
code: callbackCode,
|
||||||
|
codeVerifier: pending.codeVerifier,
|
||||||
|
redirectUri: pending.callbackUrl,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Hosted applications register once in the Stuffbox developer dashboard and send their assigned `clientId` instead of `selfHosted: true`. A first-time self-hosted installation receives a stable public client ID automatically; persist it for later connections.
|
||||||
|
|
||||||
|
## Read a user's media library
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const media = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.xyz",
|
||||||
|
accessToken: () => loadCurrentAccessToken(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = await media.listAssets({
|
||||||
|
limit: 50,
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
|
});
|
||||||
|
addToMediaPicker(page.items);
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
} while (cursor);
|
||||||
|
```
|
||||||
|
|
||||||
|
Request only the permissions your feature uses: `assets:read`, `assets:write`, and `assets:delete`.
|
||||||
|
|
||||||
|
## React uploads
|
||||||
|
|
||||||
|
The optional React entry point exports `useStuffboxUpload`, `uploadFile`, and their types:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useStuffboxUpload, type UploadTransport } from "@stuffbox/sdk/react";
|
||||||
|
|
||||||
|
const transport: UploadTransport = {
|
||||||
|
async createUpload(input, options) {
|
||||||
|
const response = await fetch("/api/stuffbox/uploads", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Could not create upload");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
async completeUpload(uploadId, input, options) {
|
||||||
|
const response = await fetch(`/api/stuffbox/uploads/${uploadId}/complete`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(input ?? {}),
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Could not complete upload");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UploadButton() {
|
||||||
|
const { state, upload, abort } = useStuffboxUpload(transport);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
if (file) void upload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{state.status === "uploading" && (
|
||||||
|
<button type="button" onClick={abort}>
|
||||||
|
Cancel {Math.round(state.progress.percent)}%
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser should call same-origin endpoints owned by your application. Those endpoints call Stuffbox with the user's access token and return only the short-lived upload session or completed asset. File bytes then travel directly from the browser to object storage.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Developer guide](https://stuffbox.xyz/developers)
|
||||||
|
- [Protocol reference](docs/protocol.md)
|
||||||
|
- [Integration examples](examples)
|
||||||
|
- [Source and issues](https://githop.xyz/GnosysLabs/stuffbox-sdk)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © Gnosys Labs. The Stuffbox service implementation is proprietary and is not included in this package or its public source repository.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
Please report suspected vulnerabilities privately to [admin@gnosyslabs.xyz](mailto:admin@gnosyslabs.xyz). Do not include access tokens, signed upload URLs, credentials, or user media in a public issue.
|
||||||
|
|
||||||
|
Security fixes are made against the latest published SDK version. We will acknowledge a report as soon as practical, investigate it, and coordinate disclosure when appropriate.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Third-Party Notices
|
||||||
|
|
||||||
|
`@stuffbox/sdk` does not bundle third-party source code.
|
||||||
|
|
||||||
|
React is an optional peer dependency of the `@stuffbox/sdk/react` entry point and is distributed separately under its own license. Dependency licenses and notices remain with their respective packages.
|
||||||
|
|
||||||
|
No UploadThing source code was copied or substantially adapted for this package.
|
||||||
|
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Stuffbox protocol v1
|
||||||
|
|
||||||
|
All control endpoints return JSON. Successful response fields use `snake_case`; SDK objects use idiomatic TypeScript names. Errors have a stable envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "quota_exceeded",
|
||||||
|
"message": "Upload exceeds remaining storage quota",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Clients must not parse the human message. Branch on `error.code`. A `429` response includes `Retry-After` when available. Token responses and canonical redirects use `Cache-Control: no-store`.
|
||||||
|
|
||||||
|
## Scopes
|
||||||
|
|
||||||
|
- `assets:read` — list the user’s active media library and retrieve usable canonical file URLs.
|
||||||
|
- `assets:write` — create and complete upload sessions.
|
||||||
|
- `assets:delete` — tombstone assets and delete their stored objects.
|
||||||
|
|
||||||
|
## Register and connect an application
|
||||||
|
|
||||||
|
Stuffbox supports two application identities:
|
||||||
|
|
||||||
|
- A conventional hosted application is registered once in the Stuffbox developer dashboard. Stuffbox assigns a public `client_id` and stores one or more exact callback URLs.
|
||||||
|
- A self-hosted application registers itself during its first connection request. Stuffbox creates or reuses an identity for that exact callback URL and returns its public `client_id`; the person running the installation does not visit Stuffbox to register it manually.
|
||||||
|
|
||||||
|
A client ID identifies routing and policy state; it is not a secret. Automatic self-hosted registration does not assert ownership of the callback domain. Exact callback binding, PKCE, state verification, user consent, and scoped tokens protect the flow.
|
||||||
|
|
||||||
|
The application creates and retains a random PKCE verifier (43–128 RFC 7636 unreserved characters), its S256 challenge, and a random state value.
|
||||||
|
|
||||||
|
### `POST /api/v1/connection-requests`
|
||||||
|
|
||||||
|
No bearer credential. Rate limited.
|
||||||
|
|
||||||
|
A conventional hosted application, or a returning self-hosted installation, sends its persisted client ID:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "app_public-client-id",
|
||||||
|
"callback_url": "https://node.example/settings/stuffbox/callback",
|
||||||
|
"code_challenge": "base64url-sha256-challenge",
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
"scopes": ["assets:read"],
|
||||||
|
"state": "node-generated-state"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A self-hosted installation without a persisted client ID sends:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registration_mode": "self_hosted",
|
||||||
|
"callback_url": "https://node.example/settings/stuffbox/callback",
|
||||||
|
"code_challenge": "base64url-sha256-challenge",
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
"scopes": ["assets:read"],
|
||||||
|
"state": "node-generated-state"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These examples request read-only gallery access. Add `assets:write` or `assets:delete` only when the application has a feature that needs that permission.
|
||||||
|
|
||||||
|
Both forms return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "cr_...",
|
||||||
|
"client_id": "app_public-client-id",
|
||||||
|
"callback_url": "https://node.example/settings/stuffbox/callback",
|
||||||
|
"authorization_url": "https://stuffbox.example/connect/cr_...",
|
||||||
|
"expires_at": "2026-07-15T00:10:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Persist the returned `client_id` and canonical `callback_url` before redirecting the user's browser to `authorization_url`. Use that pair for the authorization-code exchange and all later connection requests. If a self-hosted installation moves to a different callback URL, begin a new self-hosted registration for that exact URL.
|
||||||
|
|
||||||
|
For a managed application, the callback must exactly match one registered for its client ID. For a self-hosted application, Stuffbox creates or reuses the identity for the submitted exact callback. HTTPS callbacks are mandatory except loopback `http://localhost`, `127.0.0.1`, and `[::1]` URLs used for development. URLs cannot contain credentials or fragments.
|
||||||
|
|
||||||
|
The consent page requires a Stuffbox login and identifies the application by the exact callback domain, alongside every requested permission. Approval redirects only to the stored callback with `code` and the original `state`. The application must compare state before exchanging the code.
|
||||||
|
|
||||||
|
### `POST /api/v1/token`
|
||||||
|
|
||||||
|
Authorization-code exchange:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": "app_public-client-id",
|
||||||
|
"code": "sbc_opaque-code",
|
||||||
|
"code_verifier": "original-pkce-verifier",
|
||||||
|
"redirect_uri": "https://node.example/settings/stuffbox/callback"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh rotation:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": "sbr_opaque-refresh-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `client_id` and `redirect_uri` must be the values persisted from the connection-request response. The response contains `access_token`, `refresh_token`, `token_type: "Bearer"`, `expires_in`, and a space-delimited `scope`. Replace the old refresh token atomically with the returned one. Reuse of a consumed token revokes the entire grant and returns `refresh_token_reuse`; the application must reconnect.
|
||||||
|
|
||||||
|
### `POST /api/v1/revoke`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "token": "sbr_or_sba_value", "token_type_hint": "refresh_token" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Revokes the token's whole grant. Unknown values return success to avoid becoming a token oracle.
|
||||||
|
|
||||||
|
## Direct upload
|
||||||
|
|
||||||
|
All asset and upload endpoints use `Authorization: Bearer {access_token}`. The browser should normally call the node's same-origin upload-session proxy; the node calls Stuffbox and returns only the signed upload details.
|
||||||
|
|
||||||
|
### `POST /api/v1/uploads`
|
||||||
|
|
||||||
|
Requires `assets:write` and is rate limited.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "photo.png",
|
||||||
|
"mime_type": "image/png",
|
||||||
|
"size": 12345,
|
||||||
|
"sha256": "optional-64-character-lowercase-hex"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stuffbox validates policy and atomically reserves quota, then returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"upload_id": "upl_...",
|
||||||
|
"upload_url": "https://object-store.example/signed-put",
|
||||||
|
"method": "PUT",
|
||||||
|
"required_headers": {
|
||||||
|
"content-type": "image/png",
|
||||||
|
"if-none-match": "*"
|
||||||
|
},
|
||||||
|
"expires_at": "2026-07-15T00:10:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser sends the file body directly to `upload_url` with exactly the returned method and headers. It must not send the signed URL to logs or analytics.
|
||||||
|
|
||||||
|
### `POST /api/v1/uploads/{uploadId}/complete`
|
||||||
|
|
||||||
|
Requires `assets:write` on the grant that created the pending upload. Stuffbox loads the server-selected provider/key, performs `HEAD`, requires exact size/type, compares a provider checksum when available, and atomically turns reserved bytes into used bytes. A successful response is the asset described below. Completion is idempotent.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
### `GET /api/v1/assets`
|
||||||
|
|
||||||
|
Requires `assets:read`. The library is user-owned rather than grant-owned, so the response includes active media uploaded through any app the same Stuffbox user connected. Query parameters are `limit` (1–100, default 100) and an opaque `cursor` returned by the previous page. Results use a stable newest-first order and return `{ "items": [asset], "next_cursor": "..." }` when another page is available. Treat cursors as opaque and omit `cursor` for the first page.
|
||||||
|
|
||||||
|
### `GET /api/v1/assets/{assetId}`
|
||||||
|
|
||||||
|
Requires `assets:read` and predicates the query on the grant owner's user ID. A cross-owner ID is indistinguishable from a missing ID.
|
||||||
|
|
||||||
|
### `DELETE /api/v1/assets/{assetId}`
|
||||||
|
|
||||||
|
Requires `assets:delete`. Tombstones the asset and releases used quota in a transaction before deleting the provider object. Canonical delivery stops immediately. Provider deletion is idempotent and retryable.
|
||||||
|
|
||||||
|
Asset fields are `id`, `public_id`, `canonical_url`, `original_filename`, `mime_type`, `byte_size`, optional `sha256`, `status`, `created_at`, and optional `deleted_at`. Internal object keys and provider credentials are absent.
|
||||||
|
|
||||||
|
### `GET` or `HEAD /f/{publicAssetId}`
|
||||||
|
|
||||||
|
No bearer credential. Active assets return a temporary `307` redirect to a CDN/provider URL; Stuffbox never proxies the file body. Deleted assets return `410` and unknown IDs return `404`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist/**", "coverage/**"] },
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Integration examples
|
||||||
|
|
||||||
|
- [`server-connection.ts`](server-connection.ts) shows PKCE connection, authorization-code exchange, and gallery pagination on an application server.
|
||||||
|
- [`react-upload.tsx`](react-upload.tsx) shows a native-feeling upload control using `@stuffbox/sdk/react` and app-owned same-origin endpoints.
|
||||||
|
|
||||||
|
The same-origin endpoints in the React example are intentional. Keep Stuffbox access and refresh tokens on your server; return only the short-lived upload session or completed asset to browser code.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ChangeEvent } from "react";
|
||||||
|
import {
|
||||||
|
useStuffboxUpload,
|
||||||
|
type UploadTransport,
|
||||||
|
} from "@stuffbox/sdk/react";
|
||||||
|
|
||||||
|
async function responseJson<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Upload endpoint failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadTransport: UploadTransport = {
|
||||||
|
async createUpload(input, options) {
|
||||||
|
const response = await fetch("/api/stuffbox/uploads", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
|
});
|
||||||
|
return responseJson(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async completeUpload(uploadId, input, options) {
|
||||||
|
const response = await fetch(`/api/stuffbox/uploads/${uploadId}/complete`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(input ?? {}),
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
|
});
|
||||||
|
return responseJson(response);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StuffboxUploadButton() {
|
||||||
|
const { state, upload, abort, reset } = useStuffboxUpload(uploadTransport);
|
||||||
|
|
||||||
|
function chooseFile(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
if (file) void upload(file).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input type="file" onChange={chooseFile} disabled={state.status === "uploading"} />
|
||||||
|
|
||||||
|
{state.status === "uploading" && (
|
||||||
|
<button type="button" onClick={abort}>
|
||||||
|
Cancel upload ({Math.round(state.progress.percent)}%)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "complete" && (
|
||||||
|
<p>
|
||||||
|
Uploaded <a href={state.asset.url}>{state.asset.filename}</a>{" "}
|
||||||
|
<button type="button" onClick={reset}>Upload another</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "error" && <p role="alert">{state.error.message}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
StuffboxClient,
|
||||||
|
createPkcePair,
|
||||||
|
generateState,
|
||||||
|
type Asset,
|
||||||
|
type StuffboxScope,
|
||||||
|
type TokenSet,
|
||||||
|
} from "@stuffbox/sdk";
|
||||||
|
|
||||||
|
const stuffbox = new StuffboxClient({ baseUrl: "https://stuffbox.xyz" });
|
||||||
|
|
||||||
|
export interface PendingConnection {
|
||||||
|
clientId: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeginConnectionInput {
|
||||||
|
callbackUrl: string;
|
||||||
|
scopes: readonly StuffboxScope[];
|
||||||
|
clientId?: string;
|
||||||
|
savePending(value: PendingConnection): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function beginConnection(
|
||||||
|
input: BeginConnectionInput,
|
||||||
|
): Promise<string> {
|
||||||
|
const pkce = await createPkcePair();
|
||||||
|
const state = generateState();
|
||||||
|
const common = {
|
||||||
|
callbackUrl: input.callbackUrl,
|
||||||
|
codeChallenge: pkce.challenge,
|
||||||
|
scopes: input.scopes,
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
const connection = await stuffbox.createConnectionRequest(
|
||||||
|
input.clientId
|
||||||
|
? { ...common, clientId: input.clientId }
|
||||||
|
: { ...common, selfHosted: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
await input.savePending({
|
||||||
|
clientId: connection.clientId,
|
||||||
|
callbackUrl: connection.callbackUrl,
|
||||||
|
codeVerifier: pkce.verifier,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
return connection.authorizationUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FinishConnectionInput {
|
||||||
|
code: string;
|
||||||
|
returnedState: string;
|
||||||
|
pending: PendingConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finishConnection(
|
||||||
|
input: FinishConnectionInput,
|
||||||
|
): Promise<TokenSet> {
|
||||||
|
if (input.returnedState !== input.pending.state) {
|
||||||
|
throw new Error("Stuffbox connection state did not match");
|
||||||
|
}
|
||||||
|
|
||||||
|
return stuffbox.exchangeAuthorizationCode({
|
||||||
|
clientId: input.pending.clientId,
|
||||||
|
code: input.code,
|
||||||
|
codeVerifier: input.pending.codeVerifier,
|
||||||
|
redirectUri: input.pending.callbackUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEntireLibrary(accessToken: string): Promise<Asset[]> {
|
||||||
|
const media = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.xyz",
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
const assets: Asset[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const page = await media.listAssets({
|
||||||
|
limit: 100,
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
|
});
|
||||||
|
assets.push(...page.items);
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"name": "@stuffbox/sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Official Stuffbox SDK with framework-neutral APIs and optional React upload helpers",
|
||||||
|
"packageManager": "pnpm@10.30.3",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Gnosys Labs <admin@gnosyslabs.xyz>",
|
||||||
|
"homepage": "https://stuffbox.xyz/developers",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://githop.xyz/GnosysLabs/stuffbox-sdk.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://githop.xyz/GnosysLabs/stuffbox-sdk/issues"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"stuffbox",
|
||||||
|
"media",
|
||||||
|
"uploads",
|
||||||
|
"storage",
|
||||||
|
"react",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"src",
|
||||||
|
"LICENSE",
|
||||||
|
"README.md",
|
||||||
|
"THIRD_PARTY_NOTICES.md"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./react": {
|
||||||
|
"types": "./dist/react/index.d.ts",
|
||||||
|
"import": "./dist/react/index.js",
|
||||||
|
"default": "./dist/react/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
||||||
|
"typecheck:examples": "tsc -p tsconfig.examples.json --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint src tests examples",
|
||||||
|
"verify": "pnpm typecheck && pnpm typecheck:examples && pnpm lint && pnpm test && pnpm build",
|
||||||
|
"prepublishOnly": "pnpm verify",
|
||||||
|
"prepack": "npm run build"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.2.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.9",
|
||||||
|
"eslint": "9.31.0",
|
||||||
|
"react": "19.2.7",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "8.64.0",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1670
File diff suppressed because it is too large
Load Diff
+553
@@ -0,0 +1,553 @@
|
|||||||
|
import { StuffboxError } from "./errors.js";
|
||||||
|
import {
|
||||||
|
STUFFBOX_SCOPES,
|
||||||
|
type ApiErrorPayload,
|
||||||
|
type Asset,
|
||||||
|
type AssetPage,
|
||||||
|
type CompleteUploadInput,
|
||||||
|
type ConnectionRequest,
|
||||||
|
type CreateConnectionRequestInput,
|
||||||
|
type CreateUploadInput,
|
||||||
|
type ExchangeAuthorizationCodeInput,
|
||||||
|
type FetchLike,
|
||||||
|
type ListAssetsInput,
|
||||||
|
type RefreshTokenInput,
|
||||||
|
type RequestOptions,
|
||||||
|
type RevokeTokenInput,
|
||||||
|
type StuffboxClientOptions,
|
||||||
|
type StuffboxEndpoints,
|
||||||
|
type StuffboxScope,
|
||||||
|
type TokenSet,
|
||||||
|
type UploadSession,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export const DEFAULT_ENDPOINTS: Readonly<StuffboxEndpoints> = {
|
||||||
|
connectionRequests: "/api/v1/connection-requests",
|
||||||
|
token: "/api/v1/token",
|
||||||
|
revoke: "/api/v1/revoke",
|
||||||
|
uploads: "/api/v1/uploads",
|
||||||
|
assets: "/api/v1/assets",
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface InternalRequestOptions extends RequestOptions {
|
||||||
|
method?: "GET" | "POST" | "DELETE";
|
||||||
|
body?: unknown;
|
||||||
|
authenticated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): JsonRecord | undefined {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
? (value as JsonRecord)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataRecord(value: unknown): JsonRecord {
|
||||||
|
const root = asRecord(value);
|
||||||
|
if (!root) throw invalidResponse("Expected a JSON object");
|
||||||
|
return asRecord(root.data) ?? root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(record: JsonRecord, ...keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (record[key] !== undefined) return record[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(record: JsonRecord, ...keys: string[]): string {
|
||||||
|
const value = pick(record, ...keys);
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw invalidResponse(`Missing string field: ${keys[0]}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(record: JsonRecord, ...keys: string[]): string | undefined {
|
||||||
|
const value = pick(record, ...keys);
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw invalidResponse(`Invalid string field: ${keys[0]}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredNumber(record: JsonRecord, ...keys: string[]): number {
|
||||||
|
const value = pick(record, ...keys);
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw invalidResponse(`Missing number field: ${keys[0]}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidResponse(message: string, cause?: unknown): StuffboxError {
|
||||||
|
return new StuffboxError({
|
||||||
|
code: "invalid_response",
|
||||||
|
message,
|
||||||
|
status: 0,
|
||||||
|
cause,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScopes(value: unknown): StuffboxScope[] {
|
||||||
|
const values = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: typeof value === "string"
|
||||||
|
? value.split(/\s+/).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const scopes: StuffboxScope[] = [];
|
||||||
|
for (const scope of values) {
|
||||||
|
if (
|
||||||
|
typeof scope !== "string" ||
|
||||||
|
!STUFFBOX_SCOPES.includes(scope as StuffboxScope)
|
||||||
|
) {
|
||||||
|
throw invalidResponse("Token response included an unknown scope");
|
||||||
|
}
|
||||||
|
if (!scopes.includes(scope as StuffboxScope)) {
|
||||||
|
scopes.push(scope as StuffboxScope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConnectionRequest(value: unknown): ConnectionRequest {
|
||||||
|
const data = dataRecord(value);
|
||||||
|
return {
|
||||||
|
id: requiredString(data, "id", "request_id", "connection_request_id"),
|
||||||
|
clientId: requiredString(data, "clientId", "client_id"),
|
||||||
|
callbackUrl: requiredString(data, "callbackUrl", "callback_url"),
|
||||||
|
authorizationUrl: requiredString(
|
||||||
|
data,
|
||||||
|
"authorizationUrl",
|
||||||
|
"authorization_url",
|
||||||
|
),
|
||||||
|
expiresAt: requiredString(data, "expiresAt", "expires_at"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenSet(value: unknown): TokenSet {
|
||||||
|
const data = dataRecord(value);
|
||||||
|
const tokenType = requiredString(data, "tokenType", "token_type");
|
||||||
|
if (tokenType.toLowerCase() !== "bearer") {
|
||||||
|
throw invalidResponse("Unsupported token type");
|
||||||
|
}
|
||||||
|
const refreshTokenExpiresIn = pick(
|
||||||
|
data,
|
||||||
|
"refreshTokenExpiresIn",
|
||||||
|
"refresh_token_expires_in",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: requiredString(data, "accessToken", "access_token"),
|
||||||
|
tokenType: "Bearer",
|
||||||
|
expiresIn: requiredNumber(data, "expiresIn", "expires_in"),
|
||||||
|
refreshToken: requiredString(data, "refreshToken", "refresh_token"),
|
||||||
|
...(refreshTokenExpiresIn === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
refreshTokenExpiresIn: requiredNumber(
|
||||||
|
data,
|
||||||
|
"refreshTokenExpiresIn",
|
||||||
|
"refresh_token_expires_in",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
scopes: parseScopes(pick(data, "scopes", "scope")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUploadSession(value: unknown): UploadSession {
|
||||||
|
const data = dataRecord(value);
|
||||||
|
const method = optionalString(data, "method") ?? "PUT";
|
||||||
|
if (method !== "PUT") throw invalidResponse("Unsupported upload method");
|
||||||
|
|
||||||
|
const headersValue = pick(data, "requiredHeaders", "required_headers", "headers");
|
||||||
|
const headerRecord = headersValue === undefined ? {} : asRecord(headersValue);
|
||||||
|
if (!headerRecord) throw invalidResponse("Invalid upload headers");
|
||||||
|
|
||||||
|
const requiredHeaders: Record<string, string> = {};
|
||||||
|
for (const [name, headerValue] of Object.entries(headerRecord)) {
|
||||||
|
if (typeof headerValue !== "string") {
|
||||||
|
throw invalidResponse("Upload headers must contain strings");
|
||||||
|
}
|
||||||
|
requiredHeaders[name] = headerValue;
|
||||||
|
}
|
||||||
|
const completionToken = optionalString(
|
||||||
|
data,
|
||||||
|
"completionToken",
|
||||||
|
"completion_token",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: requiredString(data, "id", "upload_id", "pending_upload_id"),
|
||||||
|
uploadUrl: requiredString(data, "uploadUrl", "upload_url"),
|
||||||
|
method,
|
||||||
|
requiredHeaders,
|
||||||
|
expiresAt: requiredString(data, "expiresAt", "expires_at"),
|
||||||
|
...(completionToken === undefined ? {} : { completionToken }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAsset(value: unknown): Asset {
|
||||||
|
const outer = dataRecord(value);
|
||||||
|
const data = asRecord(outer.asset) ?? outer;
|
||||||
|
const status = (optionalString(data, "status") ?? "active") as Asset["status"];
|
||||||
|
if (!(["active", "deleting", "deleted"] as const).includes(status)) {
|
||||||
|
throw invalidResponse("Invalid asset status");
|
||||||
|
}
|
||||||
|
const sha256 = optionalString(data, "sha256", "sha_256");
|
||||||
|
const deletedAt = optionalString(data, "deletedAt", "deleted_at");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: requiredString(data, "id", "asset_id"),
|
||||||
|
publicId: requiredString(data, "publicId", "public_id", "public_asset_id"),
|
||||||
|
url: requiredString(data, "url", "canonical_url"),
|
||||||
|
filename: requiredString(data, "filename", "original_filename"),
|
||||||
|
mimeType: requiredString(data, "mimeType", "mime_type"),
|
||||||
|
byteSize: requiredNumber(data, "byteSize", "byte_size"),
|
||||||
|
...(sha256 === undefined ? {} : { sha256 }),
|
||||||
|
status,
|
||||||
|
createdAt: requiredString(data, "createdAt", "created_at"),
|
||||||
|
...(deletedAt === undefined ? {} : { deletedAt }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCompletedAsset(value: unknown): Asset {
|
||||||
|
return parseAsset(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssetPage(value: unknown): AssetPage {
|
||||||
|
const root = asRecord(value);
|
||||||
|
const data = root ? asRecord(root.data) ?? root : undefined;
|
||||||
|
const itemsValue = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: data
|
||||||
|
? pick(data, "items", "assets")
|
||||||
|
: undefined;
|
||||||
|
if (!Array.isArray(itemsValue)) {
|
||||||
|
throw invalidResponse("Asset list did not contain an array");
|
||||||
|
}
|
||||||
|
const nextCursor = data
|
||||||
|
? optionalString(data, "nextCursor", "next_cursor")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: itemsValue.map(parseAsset),
|
||||||
|
...(nextCursor === undefined ? {} : { nextCursor }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeHeader(headers: Record<string, string>, name: string): void {
|
||||||
|
for (const key of Object.keys(headers)) {
|
||||||
|
if (key.toLowerCase() === name.toLowerCase()) delete headers[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRetryAfter(value: string | null): number | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const seconds = Number(value);
|
||||||
|
if (Number.isFinite(seconds) && seconds >= 0) return seconds;
|
||||||
|
const date = Date.parse(value);
|
||||||
|
if (Number.isNaN(date)) return undefined;
|
||||||
|
return Math.max(0, Math.ceil((date - Date.now()) / 1_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseApiError(value: unknown, status: number): ApiErrorPayload {
|
||||||
|
const root = asRecord(value);
|
||||||
|
const error = (root && asRecord(root.error)) || root;
|
||||||
|
const rawCode = error?.code;
|
||||||
|
const rawMessage = error?.message;
|
||||||
|
const code = typeof rawCode === "string" ? rawCode : undefined;
|
||||||
|
const message = typeof rawMessage === "string" ? rawMessage : undefined;
|
||||||
|
const rawRequestId = error
|
||||||
|
? pick(error, "requestId", "request_id")
|
||||||
|
: undefined;
|
||||||
|
const rootRequestId = root
|
||||||
|
? pick(root, "requestId", "request_id")
|
||||||
|
: undefined;
|
||||||
|
const requestId =
|
||||||
|
(typeof rawRequestId === "string" ? rawRequestId : undefined) ??
|
||||||
|
(typeof rootRequestId === "string" ? rootRequestId : undefined);
|
||||||
|
const details = error?.details;
|
||||||
|
return {
|
||||||
|
code: code ?? `http_${status}`,
|
||||||
|
message: message ?? `Stuffbox request failed with status ${status}`,
|
||||||
|
...(details === undefined ? {} : { details }),
|
||||||
|
...(requestId === undefined ? {} : { requestId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"name" in value &&
|
||||||
|
value.name === "AbortError"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StuffboxClient {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
readonly endpoints: Readonly<StuffboxEndpoints>;
|
||||||
|
|
||||||
|
private readonly fetcher: FetchLike;
|
||||||
|
private readonly configuredAccessToken?: StuffboxClientOptions["accessToken"];
|
||||||
|
private readonly defaultHeaders: Readonly<Record<string, string>>;
|
||||||
|
|
||||||
|
constructor(options: StuffboxClientOptions) {
|
||||||
|
const parsedBaseUrl = new URL(options.baseUrl);
|
||||||
|
if (parsedBaseUrl.protocol !== "http:" && parsedBaseUrl.protocol !== "https:") {
|
||||||
|
throw new TypeError("Stuffbox baseUrl must use HTTP or HTTPS");
|
||||||
|
}
|
||||||
|
if (parsedBaseUrl.search || parsedBaseUrl.hash) {
|
||||||
|
throw new TypeError("Stuffbox baseUrl must not contain a query or fragment");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
||||||
|
if (!fetcher) throw new TypeError("StuffboxClient requires a fetch implementation");
|
||||||
|
|
||||||
|
this.baseUrl = parsedBaseUrl.toString().replace(/\/$/, "");
|
||||||
|
this.fetcher = fetcher as FetchLike;
|
||||||
|
this.configuredAccessToken = options.accessToken;
|
||||||
|
this.defaultHeaders = { ...options.headers };
|
||||||
|
this.endpoints = { ...DEFAULT_ENDPOINTS, ...options.endpoints };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConnectionRequest(
|
||||||
|
input: CreateConnectionRequestInput,
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<ConnectionRequest> {
|
||||||
|
const value = await this.request(this.endpoints.connectionRequests, {
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
authenticated: false,
|
||||||
|
body: {
|
||||||
|
...(input.selfHosted
|
||||||
|
? { registration_mode: "self_hosted" }
|
||||||
|
: { client_id: input.clientId }),
|
||||||
|
callback_url: input.callbackUrl,
|
||||||
|
code_challenge: input.codeChallenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
scopes: input.scopes,
|
||||||
|
state: input.state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return parseConnectionRequest(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchangeAuthorizationCode(
|
||||||
|
input: ExchangeAuthorizationCodeInput,
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<TokenSet> {
|
||||||
|
const value = await this.request(this.endpoints.token, {
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
authenticated: false,
|
||||||
|
body: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: input.clientId,
|
||||||
|
code: input.code,
|
||||||
|
code_verifier: input.codeVerifier,
|
||||||
|
redirect_uri: input.redirectUri,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return parseTokenSet(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshTokens(
|
||||||
|
input: RefreshTokenInput | string,
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<TokenSet> {
|
||||||
|
const refreshToken = typeof input === "string" ? input : input.refreshToken;
|
||||||
|
const value = await this.request(this.endpoints.token, {
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
authenticated: false,
|
||||||
|
body: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return parseTokenSet(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeToken(
|
||||||
|
input: RevokeTokenInput | string,
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const token = typeof input === "string" ? input : input.token;
|
||||||
|
const tokenTypeHint =
|
||||||
|
typeof input === "string" ? undefined : input.tokenTypeHint;
|
||||||
|
await this.request(this.endpoints.revoke, {
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
authenticated: false,
|
||||||
|
body: {
|
||||||
|
token,
|
||||||
|
...(tokenTypeHint ? { token_type_hint: tokenTypeHint } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUpload(
|
||||||
|
input: CreateUploadInput,
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<UploadSession> {
|
||||||
|
const value = await this.request(this.endpoints.uploads, {
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
filename: input.filename,
|
||||||
|
mime_type: input.mimeType,
|
||||||
|
size: input.size,
|
||||||
|
...(input.sha256 ? { sha256: input.sha256 } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return parseUploadSession(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeUpload(
|
||||||
|
uploadId: string,
|
||||||
|
input: CompleteUploadInput = {},
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<Asset> {
|
||||||
|
const value = await this.request(
|
||||||
|
`${this.endpoints.uploads}/${encodeURIComponent(uploadId)}/complete`,
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
body: input.completionToken
|
||||||
|
? { completion_token: input.completionToken }
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return parseCompletedAsset(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAssets(
|
||||||
|
input: ListAssetsInput = {},
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<AssetPage> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (input.cursor) query.set("cursor", input.cursor);
|
||||||
|
if (input.limit !== undefined) {
|
||||||
|
if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) {
|
||||||
|
throw new RangeError("Asset list limit must be between 1 and 100");
|
||||||
|
}
|
||||||
|
query.set("limit", String(input.limit));
|
||||||
|
}
|
||||||
|
const encodedQuery = query.toString();
|
||||||
|
const suffix = encodedQuery ? `?${encodedQuery}` : "";
|
||||||
|
return parseAssetPage(
|
||||||
|
await this.request(`${this.endpoints.assets}${suffix}`, options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAsset(assetId: string, options?: RequestOptions): Promise<Asset> {
|
||||||
|
return parseAsset(
|
||||||
|
await this.request(
|
||||||
|
`${this.endpoints.assets}/${encodeURIComponent(assetId)}`,
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAsset(assetId: string, options?: RequestOptions): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
`${this.endpoints.assets}/${encodeURIComponent(assetId)}`,
|
||||||
|
{ ...options, method: "DELETE" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAccessToken(
|
||||||
|
explicit: RequestOptions["accessToken"],
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (explicit === null) return undefined;
|
||||||
|
if (explicit !== undefined) return explicit;
|
||||||
|
if (typeof this.configuredAccessToken === "function") {
|
||||||
|
return this.configuredAccessToken();
|
||||||
|
}
|
||||||
|
return this.configuredAccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(
|
||||||
|
path: string,
|
||||||
|
options: InternalRequestOptions = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/json",
|
||||||
|
...this.defaultHeaders,
|
||||||
|
};
|
||||||
|
removeHeader(headers, "authorization");
|
||||||
|
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
removeHeader(headers, "content-type");
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.authenticated !== false) {
|
||||||
|
const accessToken = await this.resolveAccessToken(options.accessToken);
|
||||||
|
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
method: options.method ?? "GET",
|
||||||
|
headers,
|
||||||
|
...(options.body === undefined
|
||||||
|
? {}
|
||||||
|
: { body: JSON.stringify(options.body) }),
|
||||||
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||||
|
};
|
||||||
|
response = await this.fetcher(this.url(path), requestInit);
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof StuffboxError) throw cause;
|
||||||
|
throw new StuffboxError({
|
||||||
|
code: isAbortError(cause) ? "request_aborted" : "network_error",
|
||||||
|
message: isAbortError(cause)
|
||||||
|
? "Stuffbox request was aborted"
|
||||||
|
: "Unable to reach Stuffbox",
|
||||||
|
status: 0,
|
||||||
|
cause,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: unknown;
|
||||||
|
if (response.status !== 204) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.length > 0) {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(text);
|
||||||
|
} catch (cause) {
|
||||||
|
if (response.ok) {
|
||||||
|
throw invalidResponse("Stuffbox returned invalid JSON", cause);
|
||||||
|
}
|
||||||
|
value = { message: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = parseApiError(value, response.status);
|
||||||
|
const responseRequestId = response.headers.get("x-request-id");
|
||||||
|
if (!payload.requestId && responseRequestId) {
|
||||||
|
payload.requestId = responseRequestId;
|
||||||
|
}
|
||||||
|
throw StuffboxError.fromPayload(
|
||||||
|
payload,
|
||||||
|
response.status,
|
||||||
|
parseRetryAfter(response.headers.get("retry-after")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private url(path: string): string {
|
||||||
|
if (/^https?:\/\//i.test(path)) return path;
|
||||||
|
return `${this.baseUrl}/${path.replace(/^\/+/, "")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ApiErrorPayload } from "./types.js";
|
||||||
|
|
||||||
|
export type StuffboxErrorCode =
|
||||||
|
| "network_error"
|
||||||
|
| "request_aborted"
|
||||||
|
| "invalid_response"
|
||||||
|
| "object_upload_failed"
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
|
export interface StuffboxErrorOptions {
|
||||||
|
code: StuffboxErrorCode;
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
details?: unknown;
|
||||||
|
requestId?: string;
|
||||||
|
retryAfter?: number;
|
||||||
|
cause?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable error shape for both HTTP API errors and client-side failures. */
|
||||||
|
export class StuffboxError extends Error {
|
||||||
|
readonly code: StuffboxErrorCode;
|
||||||
|
readonly status: number;
|
||||||
|
readonly details?: unknown;
|
||||||
|
readonly requestId?: string;
|
||||||
|
readonly retryAfter?: number;
|
||||||
|
|
||||||
|
constructor(options: StuffboxErrorOptions) {
|
||||||
|
super(options.message);
|
||||||
|
this.name = "StuffboxError";
|
||||||
|
this.code = options.code;
|
||||||
|
this.status = options.status;
|
||||||
|
if (options.details !== undefined) this.details = options.details;
|
||||||
|
if (options.requestId !== undefined) this.requestId = options.requestId;
|
||||||
|
if (options.retryAfter !== undefined) this.retryAfter = options.retryAfter;
|
||||||
|
if (options.cause !== undefined) {
|
||||||
|
(this as Error & { cause?: unknown }).cause = options.cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPayload(
|
||||||
|
payload: ApiErrorPayload,
|
||||||
|
status: number,
|
||||||
|
retryAfter?: number,
|
||||||
|
): StuffboxError {
|
||||||
|
return new StuffboxError({
|
||||||
|
code: payload.code,
|
||||||
|
message: payload.message,
|
||||||
|
status,
|
||||||
|
...(payload.details === undefined ? {} : { details: payload.details }),
|
||||||
|
...(payload.requestId === undefined
|
||||||
|
? {}
|
||||||
|
: { requestId: payload.requestId }),
|
||||||
|
...(retryAfter === undefined ? {} : { retryAfter }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStuffboxError(value: unknown): value is StuffboxError {
|
||||||
|
return value instanceof StuffboxError;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export { DEFAULT_ENDPOINTS, StuffboxClient } from "./client.js";
|
||||||
|
export { isStuffboxError, StuffboxError } from "./errors.js";
|
||||||
|
export {
|
||||||
|
createPkcePair,
|
||||||
|
deriveCodeChallenge,
|
||||||
|
generateCodeVerifier,
|
||||||
|
generateState,
|
||||||
|
isValidCodeVerifier,
|
||||||
|
} from "./pkce.js";
|
||||||
|
export { STUFFBOX_SCOPES } from "./types.js";
|
||||||
|
export type {
|
||||||
|
AccessTokenProvider,
|
||||||
|
ApiErrorPayload,
|
||||||
|
Asset,
|
||||||
|
AssetPage,
|
||||||
|
AssetStatus,
|
||||||
|
CompleteUploadInput,
|
||||||
|
ConnectionRequest,
|
||||||
|
CreateConnectionRequestInput,
|
||||||
|
CreateUploadInput,
|
||||||
|
ExchangeAuthorizationCodeInput,
|
||||||
|
FetchLike,
|
||||||
|
ListAssetsInput,
|
||||||
|
MaybePromise,
|
||||||
|
RefreshTokenInput,
|
||||||
|
RequestOptions,
|
||||||
|
RevokeTokenInput,
|
||||||
|
StuffboxClientOptions,
|
||||||
|
StuffboxEndpoints,
|
||||||
|
StuffboxScope,
|
||||||
|
TokenSet,
|
||||||
|
UploadSession,
|
||||||
|
} from "./types.js";
|
||||||
|
export type { PkcePair } from "./pkce.js";
|
||||||
|
export type { StuffboxErrorCode, StuffboxErrorOptions } from "./errors.js";
|
||||||
|
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
const VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
|
||||||
|
const BASE64URL_ALPHABET =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||||
|
|
||||||
|
export interface PkcePair {
|
||||||
|
verifier: string;
|
||||||
|
challenge: string;
|
||||||
|
method: "S256";
|
||||||
|
}
|
||||||
|
|
||||||
|
function webCrypto(): Crypto {
|
||||||
|
const crypto = globalThis.crypto;
|
||||||
|
if (!crypto?.getRandomValues || !crypto.subtle) {
|
||||||
|
throw new Error("Stuffbox PKCE helpers require Web Crypto support");
|
||||||
|
}
|
||||||
|
return crypto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBytes(length: number): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
webCrypto().getRandomValues(bytes);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64Url(bytes: Uint8Array): string {
|
||||||
|
let output = "";
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.length; index += 3) {
|
||||||
|
const first = bytes[index] ?? 0;
|
||||||
|
const second = bytes[index + 1];
|
||||||
|
const third = bytes[index + 2];
|
||||||
|
const block = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
|
||||||
|
|
||||||
|
output += BASE64URL_ALPHABET[(block >>> 18) & 63];
|
||||||
|
output += BASE64URL_ALPHABET[(block >>> 12) & 63];
|
||||||
|
if (second !== undefined) output += BASE64URL_ALPHABET[(block >>> 6) & 63];
|
||||||
|
if (third !== undefined) output += BASE64URL_ALPHABET[block & 63];
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidCodeVerifier(verifier: string): boolean {
|
||||||
|
return VERIFIER_PATTERN.test(verifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCodeVerifier(length = 64): string {
|
||||||
|
if (!Number.isInteger(length) || length < 43 || length > 128) {
|
||||||
|
throw new RangeError("PKCE verifier length must be an integer from 43 to 128");
|
||||||
|
}
|
||||||
|
|
||||||
|
const byteLength = Math.ceil((length * 3) / 4);
|
||||||
|
return encodeBase64Url(randomBytes(byteLength)).slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveCodeChallenge(verifier: string): Promise<string> {
|
||||||
|
if (!isValidCodeVerifier(verifier)) {
|
||||||
|
throw new TypeError("Invalid RFC 7636 code verifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = await webCrypto().subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(verifier),
|
||||||
|
);
|
||||||
|
return encodeBase64Url(new Uint8Array(digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPkcePair(length = 64): Promise<PkcePair> {
|
||||||
|
const verifier = generateCodeVerifier(length);
|
||||||
|
return {
|
||||||
|
verifier,
|
||||||
|
challenge: await deriveCodeChallenge(verifier),
|
||||||
|
method: "S256",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateState(byteLength = 32): string {
|
||||||
|
if (!Number.isInteger(byteLength) || byteLength < 16 || byteLength > 128) {
|
||||||
|
throw new RangeError("State byte length must be an integer from 16 to 128");
|
||||||
|
}
|
||||||
|
return encodeBase64Url(randomBytes(byteLength));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export { createSdkUploadTransport, uploadFile } from "./upload.js";
|
||||||
|
export { useStuffboxUpload } from "./use-stuffbox-upload.js";
|
||||||
|
export type {
|
||||||
|
UploadFileOptions,
|
||||||
|
UploadPhase,
|
||||||
|
UploadProgress,
|
||||||
|
UploadTransport,
|
||||||
|
UploadTransportOptions,
|
||||||
|
} from "./upload.js";
|
||||||
|
export type {
|
||||||
|
UploadState,
|
||||||
|
UseStuffboxUploadOptions,
|
||||||
|
UseStuffboxUploadResult,
|
||||||
|
} from "./use-stuffbox-upload.js";
|
||||||
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import type { StuffboxClient } from "../client.js";
|
||||||
|
import { StuffboxError } from "../errors.js";
|
||||||
|
import type {
|
||||||
|
Asset,
|
||||||
|
CompleteUploadInput,
|
||||||
|
CreateUploadInput,
|
||||||
|
RequestOptions,
|
||||||
|
UploadSession,
|
||||||
|
} from "../types.js";
|
||||||
|
|
||||||
|
export type UploadPhase =
|
||||||
|
| "creating"
|
||||||
|
| "uploading"
|
||||||
|
| "completing"
|
||||||
|
| "complete";
|
||||||
|
|
||||||
|
export interface UploadProgress {
|
||||||
|
phase: UploadPhase;
|
||||||
|
loaded: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadTransportOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deliberately narrow boundary used by browser upload code. Implement it
|
||||||
|
* with same-origin endpoints so app access and refresh tokens remain on the
|
||||||
|
* integrating application's server.
|
||||||
|
*/
|
||||||
|
export interface UploadTransport {
|
||||||
|
createUpload(
|
||||||
|
input: CreateUploadInput,
|
||||||
|
options?: UploadTransportOptions,
|
||||||
|
): Promise<UploadSession>;
|
||||||
|
completeUpload(
|
||||||
|
uploadId: string,
|
||||||
|
input?: CompleteUploadInput,
|
||||||
|
options?: UploadTransportOptions,
|
||||||
|
): Promise<Asset>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadFileOptions {
|
||||||
|
sha256?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (progress: UploadProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapts an SDK client to the upload-only browser transport. Only use this when
|
||||||
|
* the client's credential is explicitly browser-safe and user-scoped. An app
|
||||||
|
* bearer or refresh token belongs behind a same-origin proxy instead.
|
||||||
|
*/
|
||||||
|
export function createSdkUploadTransport(client: StuffboxClient): UploadTransport {
|
||||||
|
return {
|
||||||
|
createUpload: (input, options) =>
|
||||||
|
client.createUpload(input, options as RequestOptions | undefined),
|
||||||
|
completeUpload: (uploadId, input, options) =>
|
||||||
|
client.completeUpload(
|
||||||
|
uploadId,
|
||||||
|
input,
|
||||||
|
options as RequestOptions | undefined,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function progress(
|
||||||
|
phase: UploadPhase,
|
||||||
|
loaded: number,
|
||||||
|
total: number,
|
||||||
|
): UploadProgress {
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
loaded,
|
||||||
|
total,
|
||||||
|
percent: total === 0 ? 0 : Math.min(100, (loaded / total) * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function putFile(
|
||||||
|
session: UploadSession,
|
||||||
|
file: File,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
onProgress: ((value: UploadProgress) => void) | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof XMLHttpRequest === "undefined") {
|
||||||
|
reject(
|
||||||
|
new StuffboxError({
|
||||||
|
code: "browser_api_unavailable",
|
||||||
|
message: "Direct upload requires XMLHttpRequest in a browser",
|
||||||
|
status: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
xhr.upload.onprogress = null;
|
||||||
|
xhr.onload = null;
|
||||||
|
xhr.onerror = null;
|
||||||
|
xhr.onabort = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const succeed = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fail = (error: StuffboxError) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
xhr.abort();
|
||||||
|
fail(
|
||||||
|
new StuffboxError({
|
||||||
|
code: "request_aborted",
|
||||||
|
message: "Upload was aborted",
|
||||||
|
status: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.open(session.method, session.uploadUrl, true);
|
||||||
|
for (const [name, value] of Object.entries(session.requiredHeaders)) {
|
||||||
|
xhr.setRequestHeader(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
const loaded = Math.min(event.loaded, file.size);
|
||||||
|
onProgress?.(progress("uploading", loaded, file.size));
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
onProgress?.(progress("uploading", file.size, file.size));
|
||||||
|
succeed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(
|
||||||
|
new StuffboxError({
|
||||||
|
code: "object_upload_failed",
|
||||||
|
message: `Object storage rejected the upload with status ${xhr.status}`,
|
||||||
|
status: xhr.status,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
xhr.onerror = () => {
|
||||||
|
fail(
|
||||||
|
new StuffboxError({
|
||||||
|
code: "network_error",
|
||||||
|
message: "The direct object-storage upload failed",
|
||||||
|
status: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
xhr.onabort = () => {
|
||||||
|
fail(
|
||||||
|
new StuffboxError({
|
||||||
|
code: "request_aborted",
|
||||||
|
message: "Upload was aborted",
|
||||||
|
status: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
onProgress?.(progress("uploading", 0, file.size));
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a session, PUTs bytes directly, then asks Stuffbox to verify it. */
|
||||||
|
export async function uploadFile(
|
||||||
|
transport: UploadTransport,
|
||||||
|
file: File,
|
||||||
|
options: UploadFileOptions = {},
|
||||||
|
): Promise<Asset> {
|
||||||
|
const transportOptions: UploadTransportOptions = options.signal
|
||||||
|
? { signal: options.signal }
|
||||||
|
: {};
|
||||||
|
options.onProgress?.(progress("creating", 0, file.size));
|
||||||
|
const session = await transport.createUpload(
|
||||||
|
{
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
size: file.size,
|
||||||
|
...(options.sha256 ? { sha256: options.sha256 } : {}),
|
||||||
|
},
|
||||||
|
transportOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
await putFile(session, file, options.signal, options.onProgress);
|
||||||
|
options.onProgress?.(progress("completing", file.size, file.size));
|
||||||
|
|
||||||
|
const asset = await transport.completeUpload(
|
||||||
|
session.id,
|
||||||
|
session.completionToken
|
||||||
|
? { completionToken: session.completionToken }
|
||||||
|
: undefined,
|
||||||
|
transportOptions,
|
||||||
|
);
|
||||||
|
options.onProgress?.(progress("complete", file.size, file.size));
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { Asset } from "../types.js";
|
||||||
|
import {
|
||||||
|
uploadFile,
|
||||||
|
type UploadFileOptions,
|
||||||
|
type UploadProgress,
|
||||||
|
type UploadTransport,
|
||||||
|
} from "./upload.js";
|
||||||
|
|
||||||
|
export type UploadState =
|
||||||
|
| { status: "idle"; progress?: undefined; asset?: undefined; error?: undefined }
|
||||||
|
| { status: "uploading"; progress: UploadProgress; asset?: undefined; error?: undefined }
|
||||||
|
| { status: "complete"; progress: UploadProgress; asset: Asset; error?: undefined }
|
||||||
|
| { status: "error"; progress?: UploadProgress; asset?: undefined; error: Error };
|
||||||
|
|
||||||
|
export interface UseStuffboxUploadOptions {
|
||||||
|
onProgress?: (progress: UploadProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseStuffboxUploadResult {
|
||||||
|
state: UploadState;
|
||||||
|
upload(file: File, options?: UploadFileOptions): Promise<Asset>;
|
||||||
|
abort(): void;
|
||||||
|
reset(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE_STATE: UploadState = { status: "idle" };
|
||||||
|
|
||||||
|
function errorValue(value: unknown): Error {
|
||||||
|
return value instanceof Error ? value : new Error("Upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStuffboxUpload(
|
||||||
|
transport: UploadTransport,
|
||||||
|
hookOptions: UseStuffboxUploadOptions = {},
|
||||||
|
): UseStuffboxUploadResult {
|
||||||
|
const [state, setState] = useState<UploadState>(IDLE_STATE);
|
||||||
|
const controllerRef = useRef<AbortController | undefined>(undefined);
|
||||||
|
const operationRef = useRef(0);
|
||||||
|
const hookOnProgress = hookOptions.onProgress;
|
||||||
|
|
||||||
|
const abort = useCallback(() => {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
operationRef.current += 1;
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
controllerRef.current = undefined;
|
||||||
|
setState(IDLE_STATE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => abort, [abort]);
|
||||||
|
|
||||||
|
const upload = useCallback(
|
||||||
|
async (file: File, options: UploadFileOptions = {}): Promise<Asset> => {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
const operation = ++operationRef.current;
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
|
||||||
|
const abortFromCaller = () => controller.abort();
|
||||||
|
if (options.signal?.aborted) controller.abort();
|
||||||
|
else options.signal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||||
|
|
||||||
|
let latestProgress: UploadProgress | undefined;
|
||||||
|
const onProgress = (nextProgress: UploadProgress) => {
|
||||||
|
latestProgress = nextProgress;
|
||||||
|
options.onProgress?.(nextProgress);
|
||||||
|
hookOnProgress?.(nextProgress);
|
||||||
|
if (operation === operationRef.current) {
|
||||||
|
setState({ status: "uploading", progress: nextProgress });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const asset = await uploadFile(transport, file, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress,
|
||||||
|
});
|
||||||
|
if (operation === operationRef.current) {
|
||||||
|
setState({
|
||||||
|
status: "complete",
|
||||||
|
progress:
|
||||||
|
latestProgress ?? {
|
||||||
|
phase: "complete",
|
||||||
|
loaded: file.size,
|
||||||
|
total: file.size,
|
||||||
|
percent: 100,
|
||||||
|
},
|
||||||
|
asset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
} catch (cause) {
|
||||||
|
const error = errorValue(cause);
|
||||||
|
if (operation === operationRef.current) {
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
...(latestProgress === undefined ? {} : { progress: latestProgress }),
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
options.signal?.removeEventListener("abort", abortFromCaller);
|
||||||
|
if (operation === operationRef.current) controllerRef.current = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hookOnProgress, transport],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { state, upload, abort, reset };
|
||||||
|
}
|
||||||
|
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
export const STUFFBOX_SCOPES = [
|
||||||
|
"assets:read",
|
||||||
|
"assets:write",
|
||||||
|
"assets:delete",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type StuffboxScope = (typeof STUFFBOX_SCOPES)[number];
|
||||||
|
|
||||||
|
export type MaybePromise<T> = T | Promise<T>;
|
||||||
|
|
||||||
|
export type AccessTokenProvider = () => MaybePromise<string | undefined>;
|
||||||
|
|
||||||
|
export type FetchLike = (
|
||||||
|
input: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
export interface StuffboxEndpoints {
|
||||||
|
connectionRequests: string;
|
||||||
|
token: string;
|
||||||
|
revoke: string;
|
||||||
|
uploads: string;
|
||||||
|
assets: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StuffboxClientOptions {
|
||||||
|
/** The Stuffbox origin, for example `https://stuffbox.example`. */
|
||||||
|
baseUrl: string | URL;
|
||||||
|
/** Useful for tests and runtimes that do not expose global `fetch`. */
|
||||||
|
fetch?: FetchLike;
|
||||||
|
/**
|
||||||
|
* A user-scoped access token or lazy token provider. Do not ship a node's
|
||||||
|
* long-lived credentials to browser code; call the SDK from its backend.
|
||||||
|
*/
|
||||||
|
accessToken?: string | AccessTokenProvider;
|
||||||
|
headers?: Readonly<Record<string, string>>;
|
||||||
|
endpoints?: Partial<StuffboxEndpoints>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/** Set to `null` to suppress the client's configured access token. */
|
||||||
|
accessToken?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateConnectionRequestFields {
|
||||||
|
callbackUrl: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
scopes: readonly StuffboxScope[];
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateConnectionRequestInput = CreateConnectionRequestFields &
|
||||||
|
(
|
||||||
|
| {
|
||||||
|
/** The public client ID of an app registered in the Stuffbox dashboard. */
|
||||||
|
clientId: string;
|
||||||
|
selfHosted?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Automatically register this exact callback as a self-hosted application. */
|
||||||
|
selfHosted: true;
|
||||||
|
clientId?: never;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ConnectionRequest {
|
||||||
|
id: string;
|
||||||
|
/** Persist this public client ID and include it when exchanging the authorization code. */
|
||||||
|
clientId: string;
|
||||||
|
/** Persist this canonical callback URL and use it as the token exchange redirect URI. */
|
||||||
|
callbackUrl: string;
|
||||||
|
authorizationUrl: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangeAuthorizationCodeInput {
|
||||||
|
clientId: string;
|
||||||
|
code: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
redirectUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenInput {
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenSet {
|
||||||
|
accessToken: string;
|
||||||
|
tokenType: "Bearer";
|
||||||
|
expiresIn: number;
|
||||||
|
refreshToken: string;
|
||||||
|
refreshTokenExpiresIn?: number;
|
||||||
|
scopes: StuffboxScope[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevokeTokenInput {
|
||||||
|
token: string;
|
||||||
|
tokenTypeHint?: "access_token" | "refresh_token";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUploadInput {
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
sha256?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadSession {
|
||||||
|
id: string;
|
||||||
|
uploadUrl: string;
|
||||||
|
method: "PUT";
|
||||||
|
requiredHeaders: Readonly<Record<string, string>>;
|
||||||
|
expiresAt: string;
|
||||||
|
/** A short-lived upload-specific capability; safe to omit when using bearer auth. */
|
||||||
|
completionToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteUploadInput {
|
||||||
|
completionToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssetStatus = "active" | "deleting" | "deleted";
|
||||||
|
|
||||||
|
export interface Asset {
|
||||||
|
id: string;
|
||||||
|
publicId: string;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
byteSize: number;
|
||||||
|
sha256?: string;
|
||||||
|
status: AssetStatus;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAssetsInput {
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetPage {
|
||||||
|
items: Asset[];
|
||||||
|
nextCursor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorPayload {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
StuffboxClient,
|
||||||
|
StuffboxError,
|
||||||
|
type FetchLike,
|
||||||
|
} from "../src";
|
||||||
|
|
||||||
|
function json(value: unknown, init: ResponseInit = {}): Response {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
headers.set("content-type", "application/json");
|
||||||
|
return new Response(JSON.stringify(value), {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StuffboxClient", () => {
|
||||||
|
it("creates a PKCE connection request using the protocol wire names", async () => {
|
||||||
|
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||||
|
json({
|
||||||
|
request_id: "cr_1",
|
||||||
|
client_id: "app_0123456789abcdefghijklmnop",
|
||||||
|
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||||
|
authorization_url: "https://stuffbox.test/authorize/cr_1",
|
||||||
|
expires_at: "2026-01-01T00:05:00.000Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test/",
|
||||||
|
fetch: fetcher,
|
||||||
|
accessToken: "must-not-leak",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.createConnectionRequest({
|
||||||
|
clientId: "app_0123456789abcdefghijklmnop",
|
||||||
|
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||||
|
codeChallenge: "c".repeat(43),
|
||||||
|
scopes: ["assets:read", "assets:write"],
|
||||||
|
state: "state-with-enough-entropy",
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
id: "cr_1",
|
||||||
|
clientId: "app_0123456789abcdefghijklmnop",
|
||||||
|
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||||
|
authorizationUrl: "https://stuffbox.test/authorize/cr_1",
|
||||||
|
expiresAt: "2026-01-01T00:05:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, init] = fetcher.mock.calls[0];
|
||||||
|
expect(url).toBe("https://stuffbox.test/api/v1/connection-requests");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(init?.headers).not.toHaveProperty("Authorization");
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual({
|
||||||
|
client_id: "app_0123456789abcdefghijklmnop",
|
||||||
|
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||||
|
code_challenge: "c".repeat(43),
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
scopes: ["assets:read", "assets:write"],
|
||||||
|
state: "state-with-enough-entropy",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("automatically registers a self-hosted callback and returns its client ID", async () => {
|
||||||
|
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||||
|
json({
|
||||||
|
request_id: "cr_self_hosted",
|
||||||
|
client_id: "app_self_hosted_0123456789",
|
||||||
|
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||||
|
authorization_url: "https://stuffbox.test/authorize/cr_self_hosted",
|
||||||
|
expires_at: "2026-01-01T00:05:00.000Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.createConnectionRequest({
|
||||||
|
selfHosted: true,
|
||||||
|
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||||
|
codeChallenge: "c".repeat(43),
|
||||||
|
scopes: ["assets:read", "assets:write"],
|
||||||
|
state: "state-with-enough-entropy",
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
id: "cr_self_hosted",
|
||||||
|
clientId: "app_self_hosted_0123456789",
|
||||||
|
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||||
|
authorizationUrl: "https://stuffbox.test/authorize/cr_self_hosted",
|
||||||
|
expiresAt: "2026-01-01T00:05:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
|
||||||
|
registration_mode: "self_hosted",
|
||||||
|
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||||
|
code_challenge: "c".repeat(43),
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
scopes: ["assets:read", "assets:write"],
|
||||||
|
state: "state-with-enough-entropy",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a connection response that omits the client ID", async () => {
|
||||||
|
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||||
|
json({
|
||||||
|
request_id: "cr_missing_client",
|
||||||
|
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||||
|
authorization_url: "https://stuffbox.test/authorize/cr_missing_client",
|
||||||
|
expires_at: "2026-01-01T00:05:00.000Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await client
|
||||||
|
.createConnectionRequest({
|
||||||
|
selfHosted: true,
|
||||||
|
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||||
|
codeChallenge: "c".repeat(43),
|
||||||
|
scopes: ["assets:read"],
|
||||||
|
state: "state-with-enough-entropy",
|
||||||
|
})
|
||||||
|
.catch((cause: unknown) => cause);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(StuffboxError);
|
||||||
|
expect(error).toMatchObject({
|
||||||
|
code: "invalid_response",
|
||||||
|
message: "Missing string field: clientId",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a connection response that omits the canonical callback URL", async () => {
|
||||||
|
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||||
|
json({
|
||||||
|
request_id: "cr_missing_callback",
|
||||||
|
client_id: "app_self_hosted_0123456789",
|
||||||
|
authorization_url: "https://stuffbox.test/authorize/cr_missing_callback",
|
||||||
|
expires_at: "2026-01-01T00:05:00.000Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await client
|
||||||
|
.createConnectionRequest({
|
||||||
|
selfHosted: true,
|
||||||
|
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||||
|
codeChallenge: "c".repeat(43),
|
||||||
|
scopes: ["assets:read"],
|
||||||
|
state: "state-with-enough-entropy",
|
||||||
|
})
|
||||||
|
.catch((cause: unknown) => cause);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(StuffboxError);
|
||||||
|
expect(error).toMatchObject({
|
||||||
|
code: "invalid_response",
|
||||||
|
message: "Missing string field: callbackUrl",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches authorization-code and refresh grants through one endpoint", async () => {
|
||||||
|
const fetcher = vi.fn<FetchLike>().mockImplementation(async () =>
|
||||||
|
json({
|
||||||
|
access_token: "at_new",
|
||||||
|
token_type: "bearer",
|
||||||
|
expires_in: 600,
|
||||||
|
refresh_token: "rt_new",
|
||||||
|
refresh_token_expires_in: 2_592_000,
|
||||||
|
scope: "assets:read assets:write",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exchanged = await client.exchangeAuthorizationCode({
|
||||||
|
clientId: "app_0123456789abcdefghijklmnop",
|
||||||
|
code: "code",
|
||||||
|
codeVerifier: "verifier",
|
||||||
|
redirectUri: "https://node.test/callback",
|
||||||
|
});
|
||||||
|
const refreshed = await client.refreshTokens("rt_old");
|
||||||
|
|
||||||
|
expect(exchanged).toEqual(refreshed);
|
||||||
|
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: "app_0123456789abcdefghijklmnop",
|
||||||
|
code: "code",
|
||||||
|
code_verifier: "verifier",
|
||||||
|
redirect_uri: "https://node.test/callback",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: "rt_old",
|
||||||
|
});
|
||||||
|
expect(fetcher.mock.calls[0][0]).toBe("https://stuffbox.test/api/v1/token");
|
||||||
|
expect(fetcher.mock.calls[1][0]).toBe("https://stuffbox.test/api/v1/token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a lazy bearer token only to protected requests", async () => {
|
||||||
|
const accessToken = vi.fn().mockResolvedValue("at_secret");
|
||||||
|
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||||
|
json({ items: [], next_cursor: "next" }),
|
||||||
|
);
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.listAssets({ limit: 10 })).resolves.toEqual({
|
||||||
|
items: [],
|
||||||
|
nextCursor: "next",
|
||||||
|
});
|
||||||
|
expect(accessToken).toHaveBeenCalledOnce();
|
||||||
|
expect(fetcher.mock.calls[0][0]).toBe(
|
||||||
|
"https://stuffbox.test/api/v1/assets?limit=10",
|
||||||
|
);
|
||||||
|
expect(fetcher.mock.calls[0][1]?.headers).toHaveProperty(
|
||||||
|
"Authorization",
|
||||||
|
"Bearer at_secret",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an asset page size outside the public API range before fetching", async () => {
|
||||||
|
const fetcher = vi.fn<FetchLike>();
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
accessToken: "at_secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.listAssets({ limit: 101 })).rejects.toThrow(
|
||||||
|
"Asset list limit must be between 1 and 100",
|
||||||
|
);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates and completes a direct upload without handling file bytes", async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<FetchLike>()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
json({
|
||||||
|
upload_id: "up_1",
|
||||||
|
upload_url: "https://objects.test/signed",
|
||||||
|
method: "PUT",
|
||||||
|
required_headers: { "Content-Type": "image/png" },
|
||||||
|
expires_at: "2026-01-01T00:10:00.000Z",
|
||||||
|
completion_token: "uct_1",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
json({
|
||||||
|
asset: {
|
||||||
|
id: "as_1",
|
||||||
|
public_id: "pub_1",
|
||||||
|
canonical_url: "https://stuffbox.test/f/pub_1",
|
||||||
|
original_filename: "image.png",
|
||||||
|
mime_type: "image/png",
|
||||||
|
byte_size: 12,
|
||||||
|
status: "active",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
accessToken: "at_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await client.createUpload({
|
||||||
|
filename: "image.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
size: 12,
|
||||||
|
});
|
||||||
|
const asset = await client.completeUpload(session.id, {
|
||||||
|
completionToken: session.completionToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.requiredHeaders).toEqual({ "Content-Type": "image/png" });
|
||||||
|
expect(asset.url).toBe("https://stuffbox.test/f/pub_1");
|
||||||
|
expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({
|
||||||
|
completion_token: "uct_1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws typed structured errors with retry metadata", async () => {
|
||||||
|
const fetcher: FetchLike = async () =>
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: "quota_exceeded",
|
||||||
|
message: "Storage quota exceeded",
|
||||||
|
details: { remaining_bytes: 4 },
|
||||||
|
request_id: "req_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 409, headers: { "retry-after": "12" } },
|
||||||
|
);
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
accessToken: "at_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await client
|
||||||
|
.createUpload({ filename: "large.mp4", mimeType: "video/mp4", size: 100 })
|
||||||
|
.catch((cause: unknown) => cause);
|
||||||
|
expect(error).toBeInstanceOf(StuffboxError);
|
||||||
|
expect(error).toMatchObject({
|
||||||
|
code: "quota_exceeded",
|
||||||
|
status: 409,
|
||||||
|
requestId: "req_1",
|
||||||
|
retryAfter: 12,
|
||||||
|
details: { remaining_bytes: 4 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports endpoint overrides and per-request bearer suppression", async () => {
|
||||||
|
const fetcher = vi.fn<FetchLike>().mockResolvedValue(json({ items: [] }));
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://example.test/custom",
|
||||||
|
fetch: fetcher,
|
||||||
|
accessToken: "at_default",
|
||||||
|
endpoints: { assets: "/assets" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.listAssets({}, { accessToken: null });
|
||||||
|
expect(fetcher.mock.calls[0][0]).toBe("https://example.test/custom/assets");
|
||||||
|
expect(fetcher.mock.calls[0][1]?.headers).not.toHaveProperty("Authorization");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes opaque tokens without adding another bearer credential", async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<FetchLike>()
|
||||||
|
.mockResolvedValue(new Response(null, { status: 204 }));
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
accessToken: "at_configured",
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.revokeToken({
|
||||||
|
token: "rt_revoked",
|
||||||
|
tokenTypeHint: "refresh_token",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetcher.mock.calls[0][0]).toBe("https://stuffbox.test/api/v1/revoke");
|
||||||
|
expect(fetcher.mock.calls[0][1]?.headers).not.toHaveProperty("Authorization");
|
||||||
|
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
|
||||||
|
token: "rt_revoked",
|
||||||
|
token_type_hint: "refresh_token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gets and deletes an owner-scoped asset by encoded ID", async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<FetchLike>()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
json({
|
||||||
|
asset: {
|
||||||
|
id: "asset/one",
|
||||||
|
public_asset_id: "pub_1",
|
||||||
|
url: "https://stuffbox.test/f/pub_1",
|
||||||
|
filename: "photo.jpg",
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
byteSize: 20,
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||||
|
const client = new StuffboxClient({
|
||||||
|
baseUrl: "https://stuffbox.test",
|
||||||
|
fetch: fetcher,
|
||||||
|
accessToken: "at_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getAsset("asset/one")).resolves.toMatchObject({
|
||||||
|
id: "asset/one",
|
||||||
|
publicId: "pub_1",
|
||||||
|
});
|
||||||
|
await client.deleteAsset("asset/one");
|
||||||
|
|
||||||
|
expect(fetcher.mock.calls[0][0]).toBe(
|
||||||
|
"https://stuffbox.test/api/v1/assets/asset%2Fone",
|
||||||
|
);
|
||||||
|
expect(fetcher.mock.calls[1][1]?.method).toBe("DELETE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createPkcePair,
|
||||||
|
deriveCodeChallenge,
|
||||||
|
generateCodeVerifier,
|
||||||
|
generateState,
|
||||||
|
isValidCodeVerifier,
|
||||||
|
} from "../src";
|
||||||
|
|
||||||
|
describe("PKCE helpers", () => {
|
||||||
|
it("derives the RFC 7636 S256 example", async () => {
|
||||||
|
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||||
|
await expect(deriveCodeChallenge(verifier)).resolves.toBe(
|
||||||
|
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates valid verifier lengths", () => {
|
||||||
|
for (const length of [43, 64, 128]) {
|
||||||
|
const verifier = generateCodeVerifier(length);
|
||||||
|
expect(verifier).toHaveLength(length);
|
||||||
|
expect(isValidCodeVerifier(verifier)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid verifier lengths and characters", async () => {
|
||||||
|
expect(() => generateCodeVerifier(42)).toThrow(RangeError);
|
||||||
|
expect(isValidCodeVerifier("a".repeat(42))).toBe(false);
|
||||||
|
await expect(deriveCodeChallenge(`${"a".repeat(42)}!`)).rejects.toThrow(
|
||||||
|
"Invalid RFC 7636 code verifier",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates an S256 pair and a high-entropy state", async () => {
|
||||||
|
const pair = await createPkcePair();
|
||||||
|
expect(pair.method).toBe("S256");
|
||||||
|
expect(pair.challenge).toBe(await deriveCodeChallenge(pair.verifier));
|
||||||
|
expect(generateState()).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
uploadFile,
|
||||||
|
type UploadProgress,
|
||||||
|
type UploadTransport,
|
||||||
|
} from "../src/react/index.js";
|
||||||
|
|
||||||
|
class FakeUploadTarget {
|
||||||
|
onprogress: ((event: ProgressEvent) => void) | null = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeXMLHttpRequest {
|
||||||
|
static latest: FakeXMLHttpRequest | undefined;
|
||||||
|
readonly upload = new FakeUploadTarget();
|
||||||
|
readonly headers: Record<string, string> = {};
|
||||||
|
status = 0;
|
||||||
|
method = "";
|
||||||
|
url = "";
|
||||||
|
body: unknown;
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onabort: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
FakeXMLHttpRequest.latest = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(method: string, url: string): void {
|
||||||
|
this.method = method;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestHeader(name: string, value: string): void {
|
||||||
|
this.headers[name.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(body: unknown): void {
|
||||||
|
this.body = body;
|
||||||
|
const size = (body as { size?: number }).size ?? 0;
|
||||||
|
this.upload.onprogress?.({ loaded: size } as ProgressEvent);
|
||||||
|
this.status = 200;
|
||||||
|
this.onload?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(): void {
|
||||||
|
this.onabort?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("direct React upload helper", () => {
|
||||||
|
const original = globalThis.XMLHttpRequest;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.XMLHttpRequest = original;
|
||||||
|
FakeXMLHttpRequest.latest = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUTs the file to object storage, reports progress, then completes", async () => {
|
||||||
|
globalThis.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest;
|
||||||
|
const file = { name: "photo.png", type: "image/png", size: 4 } as File;
|
||||||
|
const progress: UploadProgress[] = [];
|
||||||
|
const transport: UploadTransport = {
|
||||||
|
createUpload: vi.fn().mockResolvedValue({
|
||||||
|
id: "upl_1",
|
||||||
|
uploadUrl: "https://objects.example/signed-put",
|
||||||
|
method: "PUT",
|
||||||
|
requiredHeaders: { "content-type": "image/png", "if-none-match": "*" },
|
||||||
|
expiresAt: "2026-07-15T00:10:00.000Z",
|
||||||
|
}),
|
||||||
|
completeUpload: vi.fn().mockResolvedValue({
|
||||||
|
id: "ast_1",
|
||||||
|
publicId: "file_1",
|
||||||
|
url: "https://stuffbox.example/f/file_1",
|
||||||
|
filename: "photo.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
byteSize: 4,
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-07-15T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const asset = await uploadFile(transport, file, {
|
||||||
|
onProgress: (value) => progress.push(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(asset.id).toBe("ast_1");
|
||||||
|
expect(transport.createUpload).toHaveBeenCalledWith(
|
||||||
|
{ filename: "photo.png", mimeType: "image/png", size: 4 },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(FakeXMLHttpRequest.latest).toMatchObject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "https://objects.example/signed-put",
|
||||||
|
body: file,
|
||||||
|
headers: { "content-type": "image/png", "if-none-match": "*" },
|
||||||
|
});
|
||||||
|
expect(transport.completeUpload).toHaveBeenCalledWith("upl_1", undefined, {});
|
||||||
|
expect(progress.map((value) => value.phase)).toEqual([
|
||||||
|
"creating",
|
||||||
|
"uploading",
|
||||||
|
"uploading",
|
||||||
|
"uploading",
|
||||||
|
"completing",
|
||||||
|
"complete",
|
||||||
|
]);
|
||||||
|
expect(progress.at(-1)?.percent).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"declaration": false,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": ".",
|
||||||
|
"paths": {
|
||||||
|
"@stuffbox/sdk": ["./src/index.ts"],
|
||||||
|
"@stuffbox/sdk/react": ["./src/react/index.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "examples/**/*.ts", "examples/**/*.tsx"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user