diff --git a/README.md b/README.md index 84e2e6a..0e1babe 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,19 @@ import { const stuffbox = new StuffboxClient({ baseUrl: "https://stuffbox.xyz" }); const pkce = await createPkcePair(); const state = generateState(); +const savedClientId = await loadStuffboxClientId(); const connection = await stuffbox.createConnectionRequest({ - selfHosted: true, + ...(savedClientId ? { clientId: savedClientId } : {}), callbackUrl: "https://yourapp.example/api/stuffbox/callback", codeChallenge: pkce.challenge, scopes: ["assets:read"], 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({ clientId: connection.clientId, 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 diff --git a/docs/protocol.md b/docs/protocol.md index 0d12b22..ac6c096 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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: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 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. +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. The application creates and retains a random PKCE verifier (43–128 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 (43–128 RFC 7636 un 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 { - "client_id": "app_public-client-id", "callback_url": "https://node.example/settings/stuffbox/callback", "code_challenge": "base64url-sha256-challenge", "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 { - "registration_mode": "self_hosted", + "client_id": "app_public-client-id", "callback_url": "https://node.example/settings/stuffbox/callback", "code_challenge": "base64url-sha256-challenge", "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. @@ -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. +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` ```json @@ -116,7 +114,7 @@ Revokes the token's whole grant. Unknown values return success to avoid becoming ## 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` diff --git a/examples/server-connection.ts b/examples/server-connection.ts index c2dd289..d39d2a1 100644 --- a/examples/server-connection.ts +++ b/examples/server-connection.ts @@ -37,7 +37,7 @@ export async function beginConnection( const connection = await stuffbox.createConnectionRequest( input.clientId ? { ...common, clientId: input.clientId } - : { ...common, selfHosted: true }, + : common, ); await input.savePending({ diff --git a/package.json b/package.json index 2aaeae3..f64d8c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "packageManager": "pnpm@10.30.3", "license": "MIT", @@ -51,7 +51,7 @@ }, "scripts": { "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", "test": "vitest run", "lint": "eslint src tests examples", diff --git a/src/client.ts b/src/client.ts index 3af6aa8..b7a4934 100644 --- a/src/client.ts +++ b/src/client.ts @@ -323,9 +323,11 @@ export class StuffboxClient { method: "POST", authenticated: false, body: { - ...(input.selfHosted - ? { registration_mode: "self_hosted" } - : { client_id: input.clientId }), + ...(input.clientId !== undefined + ? { client_id: input.clientId } + : input.selfHosted + ? { registration_mode: "self_hosted" } + : {}), callback_url: input.callbackUrl, code_challenge: input.codeChallenge, code_challenge_method: "S256", @@ -550,4 +552,3 @@ export class StuffboxClient { return `${this.baseUrl}/${path.replace(/^\/+/, "")}`; } } - diff --git a/src/types.ts b/src/types.ts index f7cd9f2..858f970 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,14 +53,16 @@ interface 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; + /** @deprecated Omit this property. */ selfHosted?: false; } | { - /** Automatically register this exact callback as a self-hosted application. */ - selfHosted: true; + /** Omit this on the first request; Stuffbox creates the callback-bound identity automatically. */ clientId?: never; + /** @deprecated Omit this property; automatic setup is now the default. */ + selfHosted?: true; } ); @@ -151,4 +153,3 @@ export interface ApiErrorPayload { details?: unknown; requestId?: string; } - diff --git a/tests/client.test.ts b/tests/client.test.ts index 5a4bfb2..90c107d 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -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().mockResolvedValue( json({ request_id: "cr_self_hosted", @@ -78,7 +78,6 @@ describe("StuffboxClient", () => { await expect( client.createConnectionRequest({ - selfHosted: true, callbackUrl: "https://synapsis.test/stuffbox/callback", codeChallenge: "c".repeat(43), scopes: ["assets:read", "assets:write"], @@ -93,7 +92,6 @@ describe("StuffboxClient", () => { }); 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", @@ -102,6 +100,31 @@ describe("StuffboxClient", () => { }); }); + it("keeps the deprecated selfHosted option wire-compatible", async () => { + const fetcher = vi.fn().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 () => { const fetcher = vi.fn().mockResolvedValue( json({ @@ -118,7 +141,6 @@ describe("StuffboxClient", () => { const error = await client .createConnectionRequest({ - selfHosted: true, callbackUrl: "https://synapsis.test/stuffbox/callback", codeChallenge: "c".repeat(43), scopes: ["assets:read"], @@ -149,7 +171,6 @@ describe("StuffboxClient", () => { const error = await client .createConnectionRequest({ - selfHosted: true, callbackUrl: "https://synapsis.test/stuffbox/callback", codeChallenge: "c".repeat(43), scopes: ["assets:read"], @@ -397,4 +418,3 @@ describe("StuffboxClient", () => { expect(fetcher.mock.calls[1][1]?.method).toBe("DELETE"); }); }); - diff --git a/tests/connection-input-types.ts b/tests/connection-input-types.ts new file mode 100644 index 0000000..9281fdb --- /dev/null +++ b/tests/connection-input-types.ts @@ -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]; diff --git a/tsconfig.tests.json b/tsconfig.tests.json new file mode 100644 index 0000000..1a51844 --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "tests/connection-input-types.ts"] +}