Release automatic callback setup in SDK 0.2.0

Hop-State: A_06FPARPGMNVEAN3XBVH0QD8
Hop-Proposal: R_06FPARNXMP3DGP5VQGGX4Z8
Hop-Task: T_06FPAR9KH2V5ED7V65JEN00
Hop-Attempt: AT_06FPAR9KH3NZ1CV8RGPJDSG
This commit is contained in:
2026-07-15 03:46:42 -07:00
committed by Hop
parent 73a3279d5c
commit bb04d47a29
9 changed files with 88 additions and 33 deletions
+6 -3
View File
@@ -24,16 +24,19 @@ import {
const stuffbox = new StuffboxClient({ baseUrl: "https://stuffbox.xyz" }); const stuffbox = new StuffboxClient({ baseUrl: "https://stuffbox.xyz" });
const pkce = await createPkcePair(); const pkce = await createPkcePair();
const state = generateState(); const state = generateState();
const savedClientId = await loadStuffboxClientId();
const connection = await stuffbox.createConnectionRequest({ const connection = await stuffbox.createConnectionRequest({
selfHosted: true, ...(savedClientId ? { clientId: savedClientId } : {}),
callbackUrl: "https://yourapp.example/api/stuffbox/callback", callbackUrl: "https://yourapp.example/api/stuffbox/callback",
codeChallenge: pkce.challenge, codeChallenge: pkce.challenge,
scopes: ["assets:read"], scopes: ["assets:read"],
state, state,
}); });
// Persist all four values before redirecting the user's browser. // No dashboard registration is needed. Persist the returned identity and
// pending PKCE state before redirecting the user's browser.
await saveStuffboxClientId(connection.clientId);
await savePendingConnection({ await savePendingConnection({
clientId: connection.clientId, clientId: connection.clientId,
callbackUrl: connection.callbackUrl, callbackUrl: connection.callbackUrl,
@@ -57,7 +60,7 @@ const tokens = await stuffbox.exchangeAuthorizationCode({
}); });
``` ```
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. Stuffbox creates or reuses an identity for the exact callback automatically. Pass the returned `clientId` on later connections when available. The client ID is public routing information, not a secret.
## Read a user's media library ## Read a user's media library
+11 -13
View File
@@ -20,14 +20,11 @@ Clients must not parse the human message. Branch on `error.code`. A `429` respon
- `assets:write` — create and complete upload sessions. - `assets:write` — create and complete upload sessions.
- `assets:delete` — tombstone assets and delete their stored objects. - `assets:delete` — tombstone assets and delete their stored objects.
## Register and connect an application ## Connect an application
Stuffbox supports two application identities: There is no manual application-registration step. On the first connection request, Stuffbox creates or reuses a public identity bound to the submitted exact callback URL and returns its `client_id`. An app can send that client ID on later requests for the same callback.
- 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 client ID identifies routing and policy state; it is not a secret, and the callback-derived identity does not assert ownership of the callback domain. Exact callback binding, PKCE, state verification, user consent, and scoped tokens protect the flow.
- 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 (43128 RFC 7636 unreserved characters), its S256 challenge, and a random state value. The application creates and retains a random PKCE verifier (43128 RFC 7636 unreserved characters), its S256 challenge, and a random state value.
@@ -35,11 +32,10 @@ The application creates and retains a random PKCE verifier (43128 RFC 7636 un
No bearer credential. Rate limited. No bearer credential. Rate limited.
A conventional hosted application, or a returning self-hosted installation, sends its persisted client ID: On the first connection, send the exact callback with no identity field:
```json ```json
{ {
"client_id": "app_public-client-id",
"callback_url": "https://node.example/settings/stuffbox/callback", "callback_url": "https://node.example/settings/stuffbox/callback",
"code_challenge": "base64url-sha256-challenge", "code_challenge": "base64url-sha256-challenge",
"code_challenge_method": "S256", "code_challenge_method": "S256",
@@ -48,11 +44,11 @@ A conventional hosted application, or a returning self-hosted installation, send
} }
``` ```
A self-hosted installation without a persisted client ID sends: A returning app may also send the public client ID Stuffbox returned for that callback:
```json ```json
{ {
"registration_mode": "self_hosted", "client_id": "app_public-client-id",
"callback_url": "https://node.example/settings/stuffbox/callback", "callback_url": "https://node.example/settings/stuffbox/callback",
"code_challenge": "base64url-sha256-challenge", "code_challenge": "base64url-sha256-challenge",
"code_challenge_method": "S256", "code_challenge_method": "S256",
@@ -75,9 +71,9 @@ Both forms return:
} }
``` ```
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. 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 later connection requests. If an app moves to a different callback URL, omit the old client ID so Stuffbox can create or reuse the identity for the new exact callback.
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. When a client ID is sent, the callback must exactly match the URL bound to that identity. HTTPS callbacks are mandatory except loopback `http://localhost`, `127.0.0.1`, and `[::1]` URLs used for development. URLs cannot contain credentials, query strings, 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. 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.
@@ -106,6 +102,8 @@ Refresh rotation:
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. 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.
For compatibility, Stuffbox still accepts the deprecated `registration_mode: "self_hosted"` field sent by SDK 0.1.0. New integrations should omit it.
### `POST /api/v1/revoke` ### `POST /api/v1/revoke`
```json ```json
@@ -116,7 +114,7 @@ Revokes the token's whole grant. Unknown values return success to avoid becoming
## Direct upload ## 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. All asset and upload endpoints use `Authorization: Bearer {access_token}`. The browser should normally call the app's same-origin upload-session proxy; the app calls Stuffbox and returns only the signed upload details.
### `POST /api/v1/uploads` ### `POST /api/v1/uploads`
+1 -1
View File
@@ -37,7 +37,7 @@ export async function beginConnection(
const connection = await stuffbox.createConnectionRequest( const connection = await stuffbox.createConnectionRequest(
input.clientId input.clientId
? { ...common, clientId: input.clientId } ? { ...common, clientId: input.clientId }
: { ...common, selfHosted: true }, : common,
); );
await input.savePending({ await input.savePending({
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@gnosyslabs/stuffbox-sdk", "name": "@gnosyslabs/stuffbox-sdk",
"version": "0.1.0", "version": "0.2.0",
"description": "Official Stuffbox SDK with framework-neutral APIs and optional React upload helpers", "description": "Official Stuffbox SDK with framework-neutral APIs and optional React upload helpers",
"packageManager": "pnpm@10.30.3", "packageManager": "pnpm@10.30.3",
"license": "MIT", "license": "MIT",
@@ -51,7 +51,7 @@
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.build.json --noEmit", "typecheck": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.tests.json",
"typecheck:examples": "tsc -p tsconfig.examples.json --noEmit", "typecheck:examples": "tsc -p tsconfig.examples.json --noEmit",
"test": "vitest run", "test": "vitest run",
"lint": "eslint src tests examples", "lint": "eslint src tests examples",
+4 -3
View File
@@ -323,9 +323,11 @@ export class StuffboxClient {
method: "POST", method: "POST",
authenticated: false, authenticated: false,
body: { body: {
...(input.selfHosted ...(input.clientId !== undefined
? { client_id: input.clientId }
: input.selfHosted
? { registration_mode: "self_hosted" } ? { registration_mode: "self_hosted" }
: { client_id: input.clientId }), : {}),
callback_url: input.callbackUrl, callback_url: input.callbackUrl,
code_challenge: input.codeChallenge, code_challenge: input.codeChallenge,
code_challenge_method: "S256", code_challenge_method: "S256",
@@ -550,4 +552,3 @@ export class StuffboxClient {
return `${this.baseUrl}/${path.replace(/^\/+/, "")}`; return `${this.baseUrl}/${path.replace(/^\/+/, "")}`;
} }
} }
+5 -4
View File
@@ -53,14 +53,16 @@ interface CreateConnectionRequestFields {
export type CreateConnectionRequestInput = CreateConnectionRequestFields & export type CreateConnectionRequestInput = CreateConnectionRequestFields &
( (
| { | {
/** The public client ID of an app registered in the Stuffbox dashboard. */ /** A public client ID returned by an earlier connection request for this callback. */
clientId: string; clientId: string;
/** @deprecated Omit this property. */
selfHosted?: false; selfHosted?: false;
} }
| { | {
/** Automatically register this exact callback as a self-hosted application. */ /** Omit this on the first request; Stuffbox creates the callback-bound identity automatically. */
selfHosted: true;
clientId?: never; clientId?: never;
/** @deprecated Omit this property; automatic setup is now the default. */
selfHosted?: true;
} }
); );
@@ -151,4 +153,3 @@ export interface ApiErrorPayload {
details?: unknown; details?: unknown;
requestId?: string; requestId?: string;
} }
+26 -6
View File
@@ -61,7 +61,7 @@ describe("StuffboxClient", () => {
}); });
}); });
it("automatically registers a self-hosted callback and returns its client ID", async () => { it("automatically creates a callback identity when the client ID is omitted", async () => {
const fetcher = vi.fn<FetchLike>().mockResolvedValue( const fetcher = vi.fn<FetchLike>().mockResolvedValue(
json({ json({
request_id: "cr_self_hosted", request_id: "cr_self_hosted",
@@ -78,7 +78,6 @@ describe("StuffboxClient", () => {
await expect( await expect(
client.createConnectionRequest({ client.createConnectionRequest({
selfHosted: true,
callbackUrl: "https://synapsis.test/stuffbox/callback", callbackUrl: "https://synapsis.test/stuffbox/callback",
codeChallenge: "c".repeat(43), codeChallenge: "c".repeat(43),
scopes: ["assets:read", "assets:write"], scopes: ["assets:read", "assets:write"],
@@ -93,7 +92,6 @@ describe("StuffboxClient", () => {
}); });
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({ expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
registration_mode: "self_hosted",
callback_url: "https://synapsis.test/stuffbox/callback", callback_url: "https://synapsis.test/stuffbox/callback",
code_challenge: "c".repeat(43), code_challenge: "c".repeat(43),
code_challenge_method: "S256", code_challenge_method: "S256",
@@ -102,6 +100,31 @@ describe("StuffboxClient", () => {
}); });
}); });
it("keeps the deprecated selfHosted option wire-compatible", async () => {
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
json({
request_id: "cr_legacy",
client_id: "app_legacy_0123456789abcdef",
callback_url: "https://legacy.test/stuffbox/callback",
authorization_url: "https://stuffbox.test/authorize/cr_legacy",
expires_at: "2026-01-01T00:05:00.000Z",
}),
);
const client = new StuffboxClient({ baseUrl: "https://stuffbox.test", fetch: fetcher });
await client.createConnectionRequest({
selfHosted: true,
callbackUrl: "https://legacy.test/stuffbox/callback",
codeChallenge: "c".repeat(43),
scopes: ["assets:read"],
state: "state-with-enough-entropy",
});
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toMatchObject({
registration_mode: "self_hosted",
});
});
it("rejects a connection response that omits the client ID", async () => { it("rejects a connection response that omits the client ID", async () => {
const fetcher = vi.fn<FetchLike>().mockResolvedValue( const fetcher = vi.fn<FetchLike>().mockResolvedValue(
json({ json({
@@ -118,7 +141,6 @@ describe("StuffboxClient", () => {
const error = await client const error = await client
.createConnectionRequest({ .createConnectionRequest({
selfHosted: true,
callbackUrl: "https://synapsis.test/stuffbox/callback", callbackUrl: "https://synapsis.test/stuffbox/callback",
codeChallenge: "c".repeat(43), codeChallenge: "c".repeat(43),
scopes: ["assets:read"], scopes: ["assets:read"],
@@ -149,7 +171,6 @@ describe("StuffboxClient", () => {
const error = await client const error = await client
.createConnectionRequest({ .createConnectionRequest({
selfHosted: true,
callbackUrl: "https://synapsis.test/stuffbox/callback", callbackUrl: "https://synapsis.test/stuffbox/callback",
codeChallenge: "c".repeat(43), codeChallenge: "c".repeat(43),
scopes: ["assets:read"], scopes: ["assets:read"],
@@ -397,4 +418,3 @@ describe("StuffboxClient", () => {
expect(fetcher.mock.calls[1][1]?.method).toBe("DELETE"); expect(fetcher.mock.calls[1][1]?.method).toBe("DELETE");
}); });
}); });
+24
View File
@@ -0,0 +1,24 @@
import type { CreateConnectionRequestInput } from "../src/index.js";
const fields = {
callbackUrl: "https://app.example/stuffbox/callback",
codeChallenge: "c".repeat(43),
scopes: ["assets:read"] as const,
state: "state-with-enough-entropy",
};
const automatic: CreateConnectionRequestInput = fields;
const returning: CreateConnectionRequestInput = { ...fields, clientId: "app_public" };
const legacyAutomatic: CreateConnectionRequestInput = { ...fields, selfHosted: true };
const legacyReturning: CreateConnectionRequestInput = {
...fields,
clientId: "app_public",
selfHosted: false,
};
// @ts-expect-error An automatic request cannot also send a client ID.
const mixed: CreateConnectionRequestInput = { ...fields, clientId: "app_public", selfHosted: true };
// @ts-expect-error The old false marker requires a client ID.
const missingClient: CreateConnectionRequestInput = { ...fields, selfHosted: false };
void [automatic, returning, legacyAutomatic, legacyReturning, mixed, missingClient];
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/connection-input-types.ts"]
}