From d4c3c58d1a98303b099997e40bb7592b926453a0 Mon Sep 17 00:00:00 2001 From: Justina Date: Thu, 24 Sep 2026 17:01:03 +0100 Subject: [PATCH 1/3] add a sample app --- .DS_Store | Bin 0 -> 6148 bytes app-client-starter-app | 1 + 2 files changed, 1 insertion(+) create mode 100644 .DS_Store create mode 160000 app-client-starter-app diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a8b77434b51ab92700542a05c6fcfee71fdbb161 GIT binary patch literal 6148 zcmeHK!A`QlHD!WA&5CVuH zUCQRbUu1yRZW}xp;jalF*RKf)uA&EHv;o?t&hoQl3QP|YMn|M`E%d%&%z)Z)HaV~oO)p#j8#E63^3*LJdB4j>&Zzx zOjNC_J1nbW4Qlo2wA1P|?9TqdtYJ^P?H2mo_H1TVwsv+8PX_nVLoA;((E|UIN;VA6 z;TfH8z4N0xjw2c0V%>bq`B;q105iZ0tRe$uFRN5n@!|QInE__tM;V~~!A2!?4CWfu z)`5mTAF01YNP<4yB?xVUj=@|b_Miw|il|G4xnc-ij&a-MIR8OJd*3v)ve zYIcmbw0OzLGgxmtKy}S{wBm qm4xzgjqfGssG}Hj=_szFD#5r-4x(c)*N71m{t!?!uwe#%m4P?J@m8(? literal 0 HcmV?d00001 diff --git a/app-client-starter-app b/app-client-starter-app new file mode 160000 index 0000000..b2ed4b3 --- /dev/null +++ b/app-client-starter-app @@ -0,0 +1 @@ +Subproject commit b2ed4b392dc0e81f0f82d25377ece461b3b1ca85 From 32f22c78d6c7d4c4f4820278485003f9d511ce3c Mon Sep 17 00:00:00 2001 From: Justina Date: Thu, 24 Sep 2026 17:04:39 +0100 Subject: [PATCH 2/3] add app files --- app-client-starter-app | 1 - app-client-starter-app/.DS_Store | Bin 0 -> 16388 bytes app-client-starter-app/.env.example | 13 + .../.github/workflows/ci.yml | 22 + app-client-starter-app/.gitignore | 4 + app-client-starter-app/.vercelignore | 4 + app-client-starter-app/CONTRIBUTING.md | 5 + app-client-starter-app/LICENSE | 9 + app-client-starter-app/README.md | 108 +++ app-client-starter-app/SECURITY.md | 3 + app-client-starter-app/api/index.js | 3 + app-client-starter-app/lib/app.js | 175 ++++ app-client-starter-app/lib/buffer.js | 276 ++++++ app-client-starter-app/lib/config.js | 124 +++ app-client-starter-app/lib/session.js | 30 + app-client-starter-app/package-lock.json | 886 ++++++++++++++++++ app-client-starter-app/package.json | 20 + app-client-starter-app/public/app.js | 154 +++ app-client-starter-app/public/index.html | 79 ++ app-client-starter-app/public/logo.svg | 8 + app-client-starter-app/public/privacy.html | 19 + app-client-starter-app/public/styles.css | 263 ++++++ .../scripts/generate-cert.js | 31 + app-client-starter-app/server.js | 38 + app-client-starter-app/test/buffer.test.js | 110 +++ app-client-starter-app/vercel.json | 5 + 26 files changed, 2389 insertions(+), 1 deletion(-) delete mode 160000 app-client-starter-app create mode 100644 app-client-starter-app/.DS_Store create mode 100644 app-client-starter-app/.env.example create mode 100644 app-client-starter-app/.github/workflows/ci.yml create mode 100644 app-client-starter-app/.gitignore create mode 100644 app-client-starter-app/.vercelignore create mode 100644 app-client-starter-app/CONTRIBUTING.md create mode 100644 app-client-starter-app/LICENSE create mode 100644 app-client-starter-app/README.md create mode 100644 app-client-starter-app/SECURITY.md create mode 100644 app-client-starter-app/api/index.js create mode 100644 app-client-starter-app/lib/app.js create mode 100644 app-client-starter-app/lib/buffer.js create mode 100644 app-client-starter-app/lib/config.js create mode 100644 app-client-starter-app/lib/session.js create mode 100644 app-client-starter-app/package-lock.json create mode 100644 app-client-starter-app/package.json create mode 100644 app-client-starter-app/public/app.js create mode 100644 app-client-starter-app/public/index.html create mode 100644 app-client-starter-app/public/logo.svg create mode 100644 app-client-starter-app/public/privacy.html create mode 100644 app-client-starter-app/public/styles.css create mode 100644 app-client-starter-app/scripts/generate-cert.js create mode 100644 app-client-starter-app/server.js create mode 100644 app-client-starter-app/test/buffer.test.js create mode 100644 app-client-starter-app/vercel.json diff --git a/app-client-starter-app b/app-client-starter-app deleted file mode 160000 index b2ed4b3..0000000 --- a/app-client-starter-app +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b2ed4b392dc0e81f0f82d25377ece461b3b1ca85 diff --git a/app-client-starter-app/.DS_Store b/app-client-starter-app/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e048ec601be7f1ae55063d6f13310400515e8a2f GIT binary patch literal 16388 zcmeGjTWl0n^q%Q!h7NRqA}w~pLLmrQ%2KLrd6?}>c^BB0K6os%J0l&K&Xk?mZJ{(a zCjRg;erbI3rP0WT0VV!uq9XdKXo4}u5H;!#Cikxmg#zt$hROA6_=Ejl}TmgWd~vd&T!1rytLb@rS{QUm*ZH8?6sEIYZ}9G zrLxVjJtU;1QDXvtFjy6OZ!l8!88ppz+(dPOH zUN_f|jY;By+BF;Y#E)8owsVRx3Bg1`X0!V3@^m3rcfMK_#j;#|7jPv?v&!Y!YEN%p z|2{dc%*){8cMf@`W4n8el#4K~RJh3ImZW3vN*T1hC+S$}LEDWhbCa5tOk0{~v{+Wm zJYgWN!cC?e%i86*CcRBxuV;*Sw5_12x8HSAUYmcjUK8)ndMRnw*kj;L&5Y5Gw{|OU zGsiAKx-e(q;$@Glu5a8J?bxzo_q7UT?!5VxvfPjIv_11F(@1p>o4Pm9r@2YP)=m3x zPsVi3gk^Nu+MqEP!jg2YqN;k)!!@dUx30bEgn2A&ddHPoQS`?MEm7tDIzH15N2jCc z$|Vu+gQG8BqN@97OIymoQV$4Lu2SVL7nxVIpkv({S?)TDilDgk5s-9UgRFL2TEegt zU1H-jHOp#GhRI*q79clO%4%=Y85~3w-2m6ps;UR+u-i4)>l`v{e59+v*iT?vzdN9% zjLuTjjm#swIAz0`d7Z7Yoif{me2vJq@J{s?c@*|+$cFqUL@zqMe-TBLcSqC zkl)Bn@;3mKf&{am94cWEEQ2sS3XRYN&Cm*+up9P34EDnSm~aGK7=aTo3Mb)NI0a|m zES!TE;8l1X-hlJ)HoONH;S#(LAHh}l0=|T=;RgH!Kf^C@6aImJ;g(P$EEKAQYGJ7m z7FG*&LZi?mtQR&2JB3c6SI~qbf+LJD2867^9bolrDICYEylDgtE|^Ay3zHjM&6AtA zY~40}4DR|IW?~B-S{PcsYE47ark2~P$aFWWuDM3wYyJd#@ipJqj6AKq#%xhuoVC+@ zqwGeLF-Amcxk?%9y;NQ%qRA*Fthz#8DWb6`rK}pRsT0vW6~V8{Yirg?XoN};tA=YD zB~bxZSubyt&>WRwR#oMwBq_wN)U?%fh-gxZc&~K*I=Ml9C4Z1xsOxiJK2*U9h@gf) z4x3>o>NwT#Za4^W&>#Ui41x_qa16EmB#gn+Fb>baY1H;}@Ekl3FTu<33cLny!ds~E z@4&mL?;pTr_z*sUPvHuD9%%mesQ16apLx2xBdf&>!e52DoaZnn5={9T_5Ve_=cnK9 zxly8L;zl`%1Dfha8R&4HF-}<7&XBe11fFy^X(e!dO?~f#2EL@_OIp69y^l*;YH=kP z8XLG)pPqrSzc!)KaWSbQhcUwUva<>H{(l literal 0 HcmV?d00001 diff --git a/app-client-starter-app/.env.example b/app-client-starter-app/.env.example new file mode 100644 index 0000000..772a2d3 --- /dev/null +++ b/app-client-starter-app/.env.example @@ -0,0 +1,13 @@ +# Your app client, from https://publish.buffer.com/settings/api +BUFFER_CLIENT_ID=your_client_id +BUFFER_CLIENT_SECRET=your_client_secret + +# Must match what you registered, character for character +REDIRECT_URI=https://localhost:3000/callback + +# Encrypts the session cookie; 32 characters minimum. Leave it empty locally and +# the app generates one into .session-key on first boot. Required in production: +# openssl rand -hex 32 +SESSION_SECRET= + +PORT=3000 diff --git a/app-client-starter-app/.github/workflows/ci.yml b/app-client-starter-app/.github/workflows/ci.yml new file mode 100644 index 0000000..f67e25e --- /dev/null +++ b/app-client-starter-app/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run lint diff --git a/app-client-starter-app/.gitignore b/app-client-starter-app/.gitignore new file mode 100644 index 0000000..5ddf168 --- /dev/null +++ b/app-client-starter-app/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +certs/ +.env +.session-key diff --git a/app-client-starter-app/.vercelignore b/app-client-starter-app/.vercelignore new file mode 100644 index 0000000..5f0ec7f --- /dev/null +++ b/app-client-starter-app/.vercelignore @@ -0,0 +1,4 @@ +# Never upload the local TLS key, and let the platform install dependencies. +certs/ +node_modules/ +.env diff --git a/app-client-starter-app/CONTRIBUTING.md b/app-client-starter-app/CONTRIBUTING.md new file mode 100644 index 0000000..8dfeb9d --- /dev/null +++ b/app-client-starter-app/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +Keep this example small and copyable. New code should teach one clear Buffer App Client practice, include a test when it changes behavior, and avoid adding frameworks or dependencies unless they clarify the OAuth example. + +Before opening a pull request, run `npm test` and `npm run lint`. Never commit `.env`, certificates, client secrets, sessions, or real tokens. diff --git a/app-client-starter-app/LICENSE b/app-client-starter-app/LICENSE new file mode 100644 index 0000000..841d138 --- /dev/null +++ b/app-client-starter-app/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Draft Queue contributors + +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. diff --git a/app-client-starter-app/README.md b/app-client-starter-app/README.md new file mode 100644 index 0000000..ff751cb --- /dev/null +++ b/app-client-starter-app/README.md @@ -0,0 +1,108 @@ +# Draft Queue: a Buffer app client example + +A small, runnable example of a third-party app built on the [Buffer API](https://developers.buffer.com). + + +## What it does +It's a small starter app called Draft Queue which covers key components of an app client: + +1. A user connects their own Buffer account over OAuth 2.0 (Authorization Code + PKCE). +2. The app lists the channels in their Buffer organization. +3. The user picks a channel, and the app creates a **draft** post in that channel's queue. + +Use it as a starting point for your own integration. Clone it, configure it with your own app client, build new features on top, or share it with your AI coding agent as a reference. + +πŸ“˜ **Tutorial:** [Creating Buffer Client Apps](https://example.com/TODO-tutorial-url) walks through this example step by step, from registering an app client to deploying it. + +## Prerequisites + +- Node.js 22 +- A [Buffer](https://buffer.com) account +- A Buffer app client (client ID and secret). See the [authentication guide](https://developers.buffer.com/guides/authentication.html) for how to register one. +- (Optional) Vercel or another hosting provider, if you want to deploy the app + +## Setup + +1. From the root of the repository, move into this sample: + + ```bash + cd app-client-starter-app + ``` + +2. Install dependencies: + + ```bash + npm install + ``` + +3. Create your environment file: + + ```bash + cp .env.example .env + ``` + + Then fill in the values: + + | Variable | Purpose | + | --- | --- | + | `BUFFER_CLIENT_ID` | Your app client ID from Buffer. | + | `BUFFER_CLIENT_SECRET` | Required for a confidential client. Never expose it to the browser or commit it. | + | `SESSION_SECRET` | At least 32 characters. Generate one with `openssl rand -hex 32`. Optional locally, required in production. | + | `REDIRECT_URI` | Optional locally. If set, it must exactly match the redirect URI registered for your app client. | + +4. Generate a self-signed certificate for local HTTPS: + + ```bash + npm run certs + ``` + + Buffer doesn't accept `http://localhost` redirect URIs, so the dev server runs over HTTPS. If you skip this step, `npm start` generates the certificate on first run. + +## Run it locally + +```bash +npm start +``` + +Open `https://localhost:3000` and accept the self-signed development certificate in your browser. + +To run the tests and syntax checks: + +```bash +npm test +npm run lint +``` + +## Project structure + +| Path | What it holds | +| --- | --- | +| `lib/` | The app itself: OAuth and GraphQL (`buffer.js`), the session cookie (`session.js`), Express routes (`app.js`), and configuration (`config.js`). | +| `public/` | The single-page UI. | +| `server.js` | Local HTTPS dev server. | +| `api/index.js`, `vercel.json` | Serverless entry point and routing, using Vercel as an example host. | +| `test/` | Regression tests for the protocol and security-critical helpers. | +| `docs/` | Architecture notes and the production OAuth guide. | +| `scripts/` | Generates the self-signed certificate for local HTTPS. | + +## What this example covers + +- **OAuth 2.0 with PKCE:** the full Authorization Code flow with PKCE. It sends the user to Buffer to authorize the app, handles the redirect back, and exchanges the authorization code for tokens. See `lib/buffer.js`. +- **GraphQL requests:** authenticated calls to the Buffer GraphQL API. The app reads the user's organization and channels, then runs a mutation that creates a draft post in a channel's queue. +- **Refresh tokens:** the app requests the `offline_access` scope, so Buffer returns a refresh token alongside the access token. When the access token expires, the app renews it without asking the user to sign in again. Both tokens are sealed inside an HttpOnly cookie, so browser JavaScript can never read them. +- **Staying within rate limits:** the app reads the rate limit headers on each response and uses them to stay within the limits applied to the client. +- **Deployment:** a working setup for Vercel (`api/index.js` and `vercel.json`) shows how to run the same app as a serverless deployment. You can adapt it to any Node.js host. + +## Further reading + +- [Buffer developer docs](https://developers.buffer.com) +- [Architecture notes](docs/architecture.md) +- [Production OAuth guide](docs/production-oauth.md) + +## Support and contributing + +This sample is provided as-is for educational purposes and isn't covered by Buffer's product support or service-level agreements. If you find a bug or something out of date, please [open an issue](../../../issues). See the [repository README](../README.md#contributing) for how we handle contributions. + +## License + +This sample is licensed under the MIT License. See the [LICENSE](../LICENSE) file at the root of the repository. \ No newline at end of file diff --git a/app-client-starter-app/SECURITY.md b/app-client-starter-app/SECURITY.md new file mode 100644 index 0000000..b1a77c9 --- /dev/null +++ b/app-client-starter-app/SECURITY.md @@ -0,0 +1,3 @@ +# Security policy + +Do not file public issues containing Buffer tokens, client secrets, session cookies, or account data. Report a suspected vulnerability privately to the repository owner and revoke any exposed Buffer credentials immediately from [Buffer Settings β†’ API](https://publish.buffer.com/settings/api). diff --git a/app-client-starter-app/api/index.js b/app-client-starter-app/api/index.js new file mode 100644 index 0000000..b26eb66 --- /dev/null +++ b/app-client-starter-app/api/index.js @@ -0,0 +1,3 @@ +// Vercel entry point: the platform supplies the HTTP server, so this exports +// the express app as a request handler and never calls listen(). +export { default } from '../lib/app.js' diff --git a/app-client-starter-app/lib/app.js b/app-client-starter-app/lib/app.js new file mode 100644 index 0000000..d44de83 --- /dev/null +++ b/app-client-starter-app/lib/app.js @@ -0,0 +1,175 @@ +import express from 'express' +import path from 'node:path' +import { HttpError, REDIRECT_URI, withCause } from './config.js' +import { readSession } from './session.js' +import { + ACCOUNT_QUERY, + CHANNELS_QUERY, + CREATE_POST, + buildAuthorizeUrl, + buildPostInput, + createPkcePair, + createState, + exchangeCodeForTokens, + graphql, +} from './buffer.js' + +const app = express() +app.use(express.json()) + +// Every route gets the session up front; routes that change it save explicitly. +app.use(async (req, res, next) => { + try { + req.session = await readSession(req, res) + req.ctx = { req, res, session: req.session } + next() + } catch (err) { + next(err) + } +}) + +app.use(express.static(path.join(import.meta.dirname, '..', 'public'))) + +// Account data and authentication cookies must never be stored by a shared cache. +app.use(['/api', '/auth', '/callback'], (_req, res, next) => { + res.set('Cache-Control', 'no-store, private') + next() +}) + +// Lets a route throw HttpError and have it become a real response. +const wrap = (handler) => async (req, res, next) => { + try { + await handler(req, res) + } catch (err) { + if (err.status) return res.status(err.status).json({ error: err.message }) + next(err) + } +} + +app.get('/auth/login', async (req, res) => { + const { verifier, challenge } = createPkcePair() + const state = createState() + + // Note the asymmetry: we keep the verifier and the state, and send out only + // the challenge (the hash) and the state. + req.session.pkce = { codeVerifier: verifier, state } + await req.session.save() + + res.redirect(buildAuthorizeUrl({ state, codeChallenge: challenge })) +}) + +app.get('/callback', async (req, res) => { + const { code, state, error, error_description: description } = req.query + const pkce = req.session.pkce + req.session.pkce = null // one-time values β€” discard either way + + if (error) { + await req.session.save() + return res.redirect(`/?error=${encodeURIComponent(description || error)}`) + } + // CSRF check: only a callback this app started carries the state we stored. + // A missing session and a mismatched state have different causes, so they get + // different messages. + if (!pkce) { + await req.session.save() + return res.status(403).send( + 'Session lost: this callback carries no record of the login that started it. ' + + 'Usually the server restarted between Connect and coming back here (a SESSION_SECRET that changes ' + + 'invalidates every session cookie), or the flow started on a different origin than ' + + `${REDIRECT_URI} β€” localhost and 127.0.0.1 are separate cookie origins. Start again from the connect screen.`, + ) + } + if (state !== pkce.state) { + await req.session.save() + return res.status(403).send('Invalid state parameter: this callback did not come from a login this app started.') + } + + try { + // The code is short-lived, so exchange it immediately. + req.session.tokens = await exchangeCodeForTokens({ code, codeVerifier: pkce.codeVerifier }) + await req.session.save() + res.redirect('/') + } catch (err) { + console.error(JSON.stringify({ event: 'oauth_token_exchange_failed', code: err.oauthError, message: err.message })) + await req.session.save() + const message = withCause('Buffer could not complete the connection. Check your client configuration.', err.message) + res.redirect(`/?error=${encodeURIComponent(message)}`) + } +}) + +app.post('/auth/logout', async (req, res) => { + await req.session.destroy() + res.json({ connected: false }) +}) + +app.get('/api/session', wrap(async (req, res) => { + // redirectUri is echoed back for debugging: curl this endpoint to see the + // exact callback the app will send, rather than guessing from Buffer's + // generic error page. + if (!req.session.tokens) return res.json({ connected: false, redirectUri: REDIRECT_URI }) + + const data = await graphql(req.ctx, ACCOUNT_QUERY) + const { scope, connectedAt } = req.session.tokens + res.json({ + connected: true, + account: data.account, + scope, + redirectUri: REDIRECT_URI, + connectedAt, + }) +})) + +app.get('/api/channels', wrap(async (req, res) => { + const organizationId = req.query.organizationId + if (!organizationId) throw new HttpError(400, 'organizationId is required') + + const data = await graphql(req.ctx, CHANNELS_QUERY, { organizationId }) + res.json({ channels: data.channels.filter((c) => !c.isDisconnected) }) +})) + +// The one feature: add a post to a channel's queue. +app.post('/api/posts', wrap(async (req, res) => { + const { channelId, organizationId, text, mediaUrl } = req.body || {} + if (!channelId) throw new HttpError(400, 'channelId is required') + if (!organizationId) throw new HttpError(400, 'organizationId is required') + if (!text?.trim()) throw new HttpError(400, 'text is required') + // Buffer fetches the file when the post publishes, so the URL has to be + // public and served over https. + if (mediaUrl?.trim() && !/^https:\/\//i.test(mediaUrl.trim())) { + throw new HttpError(400, 'mediaUrl must be a public https URL') + } + + // `service` controls required metadata, so do not trust it from the browser. + const channels = (await graphql(req.ctx, CHANNELS_QUERY, { organizationId })).channels + const channel = channels.find((candidate) => candidate.id === channelId && !candidate.isDisconnected) + if (!channel) throw new HttpError(400, 'Select a connected channel in the chosen organization') + + const result = (await graphql(req.ctx, CREATE_POST, { + input: buildPostInput({ + channelId, + service: channel.service, + text: text.trim(), + mediaUrl: mediaUrl?.trim() || null, + }), + })).createPost + + // A typed error comes back as data, not as a thrown exception β€” the union + // member tells us the operation failed even though the request succeeded. + if (!result?.post) { + console.warn(JSON.stringify({ event: 'buffer_post_rejected', message: result?.message })) + // The union's error member carries Buffer's own reason β€” too long for the + // network, channel not found, limit reached β€” which is what a builder needs. + throw new HttpError(400, withCause('Buffer could not create that draft.', result?.message)) + } + + res.json({ post: result.post }) +})) + +// Misconfiguration (a missing SESSION_SECRET, for instance) should read as a +// clear message instead of an opaque platform 500. +app.use((err, _req, res, _next) => { + console.error(JSON.stringify({ event: 'unhandled_request_error', name: err.name, message: err.message })) + res.status(500).json({ error: withCause('Something went wrong. Please try again.', err.message) }) +}) + +export default app diff --git a/app-client-starter-app/lib/buffer.js b/app-client-starter-app/lib/buffer.js new file mode 100644 index 0000000..7d23ab7 --- /dev/null +++ b/app-client-starter-app/lib/buffer.js @@ -0,0 +1,276 @@ +import crypto from 'node:crypto' +import { + API_URL, + AUTH_URL, + CLIENT_ID, + CLIENT_SECRET, + HttpError, + REDIRECT_URI, + SCOPES, + TOKEN_URL, + withCause, +} from './config.js' + +/* ------------------------------------------------------------- oauth flow */ + +// The verifier is the secret. Its SHA-256 digest is the challenge, and only the +// challenge is ever sent to Buffer β€” the verifier stays on this server until +// the token exchange, which is what makes a stolen authorization code useless. +export function createPkcePair() { + const verifier = crypto.randomBytes(32).toString('base64url') + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') + return { verifier, challenge } +} + +// Random value echoed back by Buffer on the callback. If it doesn't match what +// we handed out, the callback didn't come from a flow we started. +export function createState() { + return crypto.randomBytes(16).toString('base64url') +} + +export function buildAuthorizeUrl({ state, codeChallenge }) { + const url = new URL(AUTH_URL) + url.search = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', // selects the Authorization Code flow + scope: SCOPES, // space-separated + state, + code_challenge: codeChallenge, // the hash β€” never the verifier + code_challenge_method: 'S256', // tells Buffer the challenge is a SHA-256 hash + prompt: 'consent', + }).toString() + return url.toString() +} + +// What the token endpoint's error codes usually mean in practice. Naming the +// likely cause saves the reader a trip through the OAuth spec. +const TOKEN_ERROR_HINTS = { + invalid_client: 'set BUFFER_CLIENT_SECRET for a confidential client, or omit it entirely for a public one', + invalid_grant: 'the code was already used or has expired, or redirect_uri differs from the authorize request', + invalid_scope: 'a requested scope is not enabled on this app client', + unsupported_grant_type: 'this client is not allowed to use that grant type', +} + +// The authorization server advertises only "none" and "client_secret_post", so +// the secret always goes in the form body β€” never HTTP Basic. +export async function requestTokens(params, previous = null) { + const body = new URLSearchParams({ client_id: CLIENT_ID, ...params }) + if (CLIENT_SECRET) body.set('client_secret', CLIENT_SECRET) + + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + const json = await res.json().catch(() => ({})) + + if (!res.ok || json.error) { + const detail = json.error_description || json.error || `HTTP ${res.status}` + const hint = TOKEN_ERROR_HINTS[json.error] + const error = new Error(hint ? `${detail} β€” ${hint}` : detail) + error.oauthError = json.error + throw error + } + + return { + accessToken: json.access_token, + // Buffer replaces a refresh token when it is used. Never retain the old + // value after a successful refresh. + refreshToken: json.refresh_token || null, + scope: json.scope || previous?.scope, + expiresAt: Date.now() + (Number(json.expires_in) || 3600) * 1000 - 30_000, + connectedAt: previous?.connectedAt || Date.now(), + } +} + +// Trade the one-time code for tokens. This is where the code_verifier is +// finally revealed: Buffer re-hashes it, compares it to the challenge from the +// authorize step, and only trusts us if they match. +export async function exchangeCodeForTokens({ code, codeVerifier }) { + const tokens = await requestTokens({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, // must byte-match the authorize request + code_verifier: codeVerifier, + }) + if (!tokens.refreshToken) throw new Error('Buffer did not return a refresh token; enable offline_access for this app client') + return tokens +} + +export async function refreshTokens(previous) { + const replacement = await requestTokens({ + grant_type: 'refresh_token', + refresh_token: previous.refreshToken, + }, previous) + + // A replacement is required for a rotating-token flow. Continuing with the + // old token would make a later failure hard to diagnose and unsafe to retry. + if (!replacement.refreshToken) throw new Error('Buffer did not return a replacement refresh token') + return replacement +} + +// The gate every API call passes through. It saves a rotated token pair before +// returning, so the next request uses Buffer's replacement refresh token. +async function accessToken(ctx) { + const tokens = ctx.session.tokens + if (!tokens) throw new HttpError(401, 'Not connected to Buffer') + if (Date.now() < tokens.expiresAt) return tokens.accessToken + + if (!tokens.refreshToken) { + ctx.session.tokens = null + await ctx.session.save() + throw new HttpError(401, 'Your Buffer session expired. Connect again to continue.') + } + + try { + ctx.session.tokens = await refreshTokens(tokens) + await ctx.session.save() + return ctx.session.tokens.accessToken + } catch (err) { + console.warn(JSON.stringify({ event: 'buffer_token_refresh_failed', code: err.oauthError })) + ctx.session.tokens = null + await ctx.session.save() + throw new HttpError(401, 'Your Buffer session expired. Connect again to continue.') + } +} + +/* --------------------------------------------------------------- graphql */ +// Everything the app reads or writes goes through one GraphQL endpoint at +// https://api.buffer.com, with the access token in an Authorization header. + +// Rate limits are per client, so every user of this app shares one quota. A +// well-behaved client reads Retry-After and backs off rather than hammering. +const MAX_BACKOFF_SECONDS = 5 + +export async function graphql(ctx, query, variables, { backedOff = false } = {}) { + const token = await accessToken(ctx) + const res = await fetch(API_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }) + const json = await res.json().catch(() => null) + + if (res.status === 401) { + ctx.session.tokens = null + await ctx.session.save() + throw new HttpError(401, 'Buffer rejected the access token β€” reconnect') + } + + if (res.status === 429) { + const retryAfter = Number(res.headers.get('Retry-After')) || 0 + // Wait out a short reset once. A long one is not worth holding a request + // open for β€” serverless functions bill wall-clock time and time out β€” so + // it is reported instead, with the wait the API asked for. + if (!backedOff && retryAfter > 0 && retryAfter <= MAX_BACKOFF_SECONDS) { + await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)) + return graphql(ctx, query, variables, { backedOff: true }) + } + throw new HttpError(429, `Buffer API rate limit reached${retryAfter ? ` β€” try again in ${retryAfter}s` : ''}`) + } + + // A GraphQL request that fails still comes back as HTTP 200. The real status + // is in the `errors` array, under extensions.code. + if (!res.ok || json?.errors?.length) { + const failure = json?.errors?.[0] + const code = failure?.extensions?.code + console.error(JSON.stringify({ event: 'buffer_api_error', status: res.status, code, message: failure?.message })) + throw new HttpError(502, withCause( + 'Buffer could not complete the request. Please try again.', + [code, failure?.message].filter(Boolean).join(': ') || `HTTP ${res.status}`, + )) + } + return json.data +} + +export const ACCOUNT_QUERY = ` + query Account { + account { + id + email + name + organizations { id name } + } + } +` + +// A "channel" is any social account connected to Buffer β€” a LinkedIn profile, +// an Instagram account, and so on. We scope the query to one organization. +export const CHANNELS_QUERY = ` + query Channels($organizationId: OrganizationId!) { + channels(input: { organizationId: $organizationId }) { + id + name + displayName + service + avatar + isDisconnected + isQueuePaused + } + } +` + +// createPost returns a UNION: either PostActionSuccess or one of several typed +// error types. MutationError is the interface all of those errors implement, so +// a single `... on MutationError` block catches every one of them, and a failed +// operation arrives as data you can show the user rather than as an exception. +export const CREATE_POST = ` + mutation CreatePost($input: CreatePostInput!) { + createPost(input: $input) { + ... on PostActionSuccess { + post { id text status dueAt channelId } + } + ... on MutationError { message } + } + } +` + +// Networks do not all accept the same post. These are the metadata fields the +// schema marks non-null for a service, so a post to that service fails +// validation without them. Every other service needs no metadata at all. +const REQUIRED_METADATA = { + instagram: { type: 'post', shouldShareToFeed: true }, + facebook: { type: 'post' }, + googlebusiness: { type: 'whats_new' }, +} + +// PostInputMetaData calls Google Business Profile `google`; every other service +// uses the same name the channel reports. +const METADATA_FIELD = { googlebusiness: 'google' } + +const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.m4v', '.webm'] + +// One hosted URL becomes one asset. Whether it is an image or a video changes +// how the networks treat it, and the extension is all there is to go on. +function assetsFor(mediaUrl) { + if (!mediaUrl) return [] + const path = new URL(mediaUrl).pathname.toLowerCase() + const isVideo = VIDEO_EXTENSIONS.some((extension) => path.endsWith(extension)) + return [isVideo ? { video: { url: mediaUrl } } : { image: { url: mediaUrl } }] +} + +// Assembles CreatePostInput. Two of its required fields are easy to miss: +// `assets` and `needsApproval` are non-null in the schema, so the mutation +// fails validation without them even when there is nothing to put there. +export function buildPostInput({ channelId, service, text, mediaUrl }) { + const input = { + channelId, + text, + assets: assetsFor(mediaUrl), + needsApproval: false, + mode: 'addToQueue', // next free slot in the channel's queue + schedulingType: 'automatic', + saveToDraft: true, // keeps it a draft; drop it to publish for real + } + + const fields = { ...REQUIRED_METADATA[service] } + // YouTube shows a title rather than the caption, so it gets the first line. + if (service === 'youtube') fields.title = text.split('\n')[0].slice(0, 100) + + if (Object.keys(fields).length) { + input.metadata = { [METADATA_FIELD[service] || service]: fields } + } + + return input +} diff --git a/app-client-starter-app/lib/config.js b/app-client-starter-app/lib/config.js new file mode 100644 index 0000000..3e4779d --- /dev/null +++ b/app-client-starter-app/lib/config.js @@ -0,0 +1,124 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +// Anything deployed sets NODE_ENV=production (hosts set it for you; set it +// yourself if yours does not). A .env file is for local development only: in +// production the real environment is authoritative, and a stray committed .env +// must not shadow it. +export const IS_PRODUCTION = process.env.NODE_ENV === 'production' + +if (!IS_PRODUCTION) { + try { + process.loadEnvFile('.env') + } catch { + // No .env file (or an older Node) β€” use real environment variables. + } +} + +export const PORT = Number(process.env.PORT || 3000) + +// Your own client, from https://publish.buffer.com/settings/api. No default on +// purpose: a fallback would mean users consenting to someone else's client. +// Trimmed because a pasted value easily carries a stray newline. +export const CLIENT_ID = process.env.BUFFER_CLIENT_ID?.trim() +if (!CLIENT_ID) { + throw new Error('BUFFER_CLIENT_ID is not set. Create an app client at https://publish.buffer.com/settings/api and put its client ID in your .env (or your host\'s environment variables).') +} +export const CLIENT_SECRET = process.env.BUFFER_CLIENT_SECRET?.trim() || null + +// OAuth redirect URIs must match what is registered with Buffer exactly. +// On Vercel, default to the stable production domain β€” preview URLs change +// every deploy and would never match a registered URI. +function defaultRedirectUri() { + const productionUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL + if (productionUrl) return `https://${productionUrl}/callback` + return `https://localhost:${PORT}/callback` +} + +// Buffer rejects a malformed redirect URI with a generic error, so catch the +// usual paste damage here and say what was wrong. +function sanitizeRedirectUri(value) { + const [first, ...rest] = value.trim().split(/\s+/) + if (rest.length) { + console.warn(`REDIRECT_URI contained extra content and was truncated to ${first}. Check the environment variable β€” it should hold one URL and nothing else.`) + } + try { + const url = new URL(first) + if (url.protocol !== 'https:') console.warn(`REDIRECT_URI is not https (${first}); Buffer requires https.`) + return url.toString() + } catch { + console.warn(`REDIRECT_URI is not a valid URL (${JSON.stringify(value)}); falling back to ${defaultRedirectUri()}.`) + return defaultRedirectUri() + } +} + +export const REDIRECT_URI = process.env.REDIRECT_URI + ? sanitizeRedirectUri(process.env.REDIRECT_URI) + : defaultRedirectUri() + +// The OAuth flow spans two requests, so a key that changed on each boot would +// break any login that straddled a restart. Persist the development one. +const DEV_KEY_FILE = path.join(import.meta.dirname, '..', '.session-key') + +function developmentSecret() { + try { + const existing = fs.readFileSync(DEV_KEY_FILE, 'utf8').trim() + if (existing.length >= 32) return existing + } catch { + // No key yet β€” generate one below. + } + + const generated = crypto.randomBytes(32).toString('hex') + try { + fs.writeFileSync(DEV_KEY_FILE, `${generated}\n`, { mode: 0o600 }) + console.warn(`SESSION_SECRET is not set; generated a development key in .session-key (gitignored). Sessions will survive restarts. Set a real one before deploying.`) + } catch { + console.warn('SESSION_SECRET is not set and .session-key could not be written; using a per-boot key. Sessions will end whenever the server restarts.') + } + return generated +} + +// The key that seals the session cookie; 32 characters minimum. In production +// it must come from the environment β€” separate instances generating their own +// could never agree β€” so fail loudly instead of silently losing sessions. +function sessionSecret() { + const secret = process.env.SESSION_SECRET?.trim() + if (secret && secret.length >= 32) return secret + + if (IS_PRODUCTION) { + throw new Error( + secret + ? 'SESSION_SECRET must be at least 32 characters. Generate one with `openssl rand -hex 32`.' + : 'SESSION_SECRET is not set. Generate one with `openssl rand -hex 32` and add it to your host\'s environment variables.', + ) + } + if (secret) console.warn(`SESSION_SECRET is only ${secret.length} characters; 32 are required, so it is being ignored.`) + return developmentSecret() +} + +export const SESSION_SECRET = sessionSecret() + +// Endpoints are published at https://auth.buffer.com/.well-known/openid-configuration +export const AUTH_URL = 'https://auth.buffer.com/auth' +export const TOKEN_URL = 'https://auth.buffer.com/token' +export const API_URL = 'https://api.buffer.com' + +// Ask for exactly what the app uses: every extra permission on the consent +// screen is another reason not to click Approve. `offline_access` lets this +// starter renew an expired access token without placing either token in page +// JavaScript. See docs/production-oauth.md before using this pattern at scale. +export const SCOPES = 'account:read posts:write offline_access' + +// Someone building against the API needs Buffer's own words to debug; someone +// using the deployed app must not see internals. Same call site, both audiences: +// the cause is appended in development and logged (only) in production. +export function withCause(message, cause) { + return !IS_PRODUCTION && cause ? `${message} (${cause})` : message +} + +export class HttpError extends Error { + constructor(status, message) { + super(message) + this.status = status + } +} diff --git a/app-client-starter-app/lib/session.js b/app-client-starter-app/lib/session.js new file mode 100644 index 0000000..2d9a7ae --- /dev/null +++ b/app-client-starter-app/lib/session.js @@ -0,0 +1,30 @@ +// Sessions live in a cookie sealed by iron-session, not in server memory: a +// serverless deploy gets a fresh process per invocation, so an in-memory store +// would lose the PKCE verifier between /auth/login and /callback. +import { getIronSession } from 'iron-session' +import { SESSION_SECRET } from './config.js' + +const options = { + password: SESSION_SECRET, + cookieName: 'bsess', + // This sealed, HttpOnly cookie holds the token pair for the runnable + // starter. A 30-day TTL lets a refresh token keep the connection alive. + ttl: 30 * 24 * 60 * 60, + cookieOptions: { + httpOnly: true, // out of reach of JavaScript + secure: true, // HTTPS only β€” we run TLS locally too + sameSite: 'lax', // fine: our callback is a plain top-level GET + path: '/', + }, +} + +// Returns the session for this request. Mutate it, then `await session.save()` +// to write the cookie back; `await session.destroy()` clears it. +// +// This is deliberately the simplest viable pattern. It prevents page +// JavaScript from reading tokens, but cannot coordinate refreshes from two +// concurrent requests. For a production shared-store design, see +// docs/production-oauth.md. +export function readSession(req, res) { + return getIronSession(req, res, options) +} diff --git a/app-client-starter-app/package-lock.json b/app-client-starter-app/package-lock.json new file mode 100644 index 0000000..33fbd9a --- /dev/null +++ b/app-client-starter-app/package-lock.json @@ -0,0 +1,886 @@ +{ + "name": "buffer-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "buffer-app", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.2", + "iron-session": "^9.0.1" + }, + "engines": { + "node": "22.x" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/iron-session": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-9.0.1.tgz", + "integrity": "sha512-ELSZdcLvA0AMhxUCY+rwKCh42UV88SqEb1ia/bHLF9G+6kScikO6mShtlIq+OtF2UlkZP0dmI2dMdG9vh4rebA==", + "funding": [ + "https://github.com/sponsors/vvo", + "https://github.com/sponsors/brc-dd" + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.1", + "iron-webcrypto": "^2.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/iron-session/node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iron-webcrypto": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-2.0.0.tgz", + "integrity": "sha512-rtffZKDUHciZElM8mjFCufBC7nVhCxHYyWHESqs89OioEDz4parOofd8/uhrejh/INhQFfYQfByS22LlezR9sQ==", + "license": "MIT", + "dependencies": { + "uint8array-extras": "^1.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/app-client-starter-app/package.json b/app-client-starter-app/package.json new file mode 100644 index 0000000..3a6a76c --- /dev/null +++ b/app-client-starter-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "buffer-app", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Minimal Buffer OAuth (PKCE) app: list connected channels and add a draft post to a queue", + "scripts": { + "start": "node server.js", + "certs": "node scripts/generate-cert.js", + "test": "node --test", + "lint": "node --check server.js && node --check api/index.js && node --check lib/app.js && node --check lib/buffer.js && node --check lib/config.js && node --check lib/session.js && node --check public/app.js" + }, + "engines": { + "node": "22.x" + }, + "dependencies": { + "express": "^4.21.2", + "iron-session": "^9.0.1" + } +} diff --git a/app-client-starter-app/public/app.js b/app-client-starter-app/public/app.js new file mode 100644 index 0000000..5c72398 --- /dev/null +++ b/app-client-starter-app/public/app.js @@ -0,0 +1,154 @@ +const $ = (id) => document.getElementById(id) +const banner = $('banner') + +function showError(message) { + banner.textContent = message + banner.hidden = !message +} + +function option(value, label, service = null) { + const element = document.createElement('option') + element.value = value + element.textContent = label + if (service) element.dataset.service = service + return element +} + +// Channel names and API messages are remote data, so everything is built with +// DOM APIs and textContent rather than innerHTML. A test enforces this. +function setMessage(element, lines) { + element.replaceChildren() + lines.forEach((line, index) => { + if (index) element.append(document.createElement('br')) + element.append(typeof line === 'string' ? document.createTextNode(line) : line) + }) +} + +// The page never handles a Buffer token: it calls this app's own endpoints and +// the server attaches the token. Errors arrive as { error } with an HTTP status. +async function api(path, options) { + const res = await fetch(path, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }) + const body = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(body.error || `Request failed (${res.status})`) + return body +} + +// Surface an OAuth error that came back on the redirect, then clean the URL. +const params = new URLSearchParams(location.search) +if (params.get('error')) { + showError(params.get('error')) + history.replaceState({}, '', location.pathname) +} + +async function loadChannels(organizationId) { + const channel = $('channel') + channel.replaceChildren(option('', 'Loading…')) + + const { channels } = await api(`/api/channels?organizationId=${encodeURIComponent(organizationId)}`) + if (!channels.length) { + channel.replaceChildren(option('', 'No connected channels')) + return + } + channel.replaceChildren(...channels.map((c) => { + const paused = c.isQueuePaused ? ' β€” queue paused' : '' + return option(c.id, `${c.displayName || c.name} (${c.service})${paused}`, c.service) + })) + describeChannel() +} + +// Some networks will not publish a post that carries no media, so say so as +// soon as the channel is picked rather than after the API refuses it. +const MEDIA_FIRST = { instagram: 'Instagram', pinterest: 'Pinterest', tiktok: 'TikTok', youtube: 'YouTube' } + +function selectedService() { + const channel = $('channel') + return channel.options[channel.selectedIndex]?.dataset.service || '' +} + +function describeChannel() { + const network = MEDIA_FIRST[selectedService()] + const hint = $('media-hint') + hint.textContent = network ? `${network} posts need an image or video.` : '' + hint.hidden = !network +} + +$('channel').addEventListener('change', describeChannel) + +async function start() { + const session = await api('/api/session') + + if (!session.connected) { + $('connect').hidden = false + return + } + + $('app').hidden = false + const { account } = session + $('account').textContent = `Connected as ${account.name || account.email}` + + const grant = $('grant') + grant.textContent = 'Tokens stay in a sealed HttpOnly cookie, and access renews automatically. Reconnect if that ever fails.' + grant.hidden = false + + const organization = $('organization') + organization.replaceChildren(...account.organizations.map((o) => option(o.id, o.name))) + organization.disabled = account.organizations.length === 1 + + organization.addEventListener('change', () => { + loadChannels(organization.value).catch((err) => showError(err.message)) + }) + await loadChannels(organization.value) +} + +$('form').addEventListener('submit', async (event) => { + event.preventDefault() + const submit = $('submit') + const result = $('result') + showError('') + submit.disabled = true + submit.textContent = 'Working…' + + try { + const { post } = await api('/api/posts', { + method: 'POST', + body: JSON.stringify({ + channelId: $('channel').value, + organizationId: $('organization').value, + text: $('text').value, + mediaUrl: $('media').value, + }), + }) + + result.className = 'result ok' + const details = document.createElement('code') + details.textContent = `post ${post.id} Β· status ${post.status}` + const link = document.createElement('a') + link.href = 'https://publish.buffer.com' + link.target = '_blank' + link.rel = 'noopener noreferrer' + link.textContent = 'Open Buffer' + setMessage(result, ['A post draft is created.', details, link]) + $('text').value = '' + $('media').value = '' + } catch (err) { + result.className = 'result bad' + result.textContent = err.message + } finally { + result.hidden = false + submit.disabled = false + submit.textContent = 'Add draft to queue' + } +}) + +$('logout').addEventListener('click', async () => { + await api('/auth/logout', { method: 'POST' }) + location.reload() +}) + +start().catch((err) => { + showError(err.message) + $('connect').hidden = false +}) diff --git a/app-client-starter-app/public/index.html b/app-client-starter-app/public/index.html new file mode 100644 index 0000000..6798b94 --- /dev/null +++ b/app-client-starter-app/public/index.html @@ -0,0 +1,79 @@ + + + + + + Draft Queue + + + + + + + +
+
+ +
+

Draft Queue

+

List the channels connected to your Buffer account, and add a draft post to one of their queues.

+
+
+ + + + + + + +
An example third-party app built on the Buffer API, created and maintained by the Buffer team. Not an official product.
+
+ + + + diff --git a/app-client-starter-app/public/logo.svg b/app-client-starter-app/public/logo.svg new file mode 100644 index 0000000..10ea215 --- /dev/null +++ b/app-client-starter-app/public/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app-client-starter-app/public/privacy.html b/app-client-starter-app/public/privacy.html new file mode 100644 index 0000000..932d207 --- /dev/null +++ b/app-client-starter-app/public/privacy.html @@ -0,0 +1,19 @@ + + + + + + Draft Queue privacy + + + +
+

Privacy

How this sample app handles data.

+
+

Draft Queue uses Buffer OAuth only to read the connected account's organizations and channels and to create a draft requested by the user.

+

Access tokens are kept in an encrypted, HTTP-only session cookie and are not exposed to browser JavaScript. This sample does not use analytics, sell personal data, or persist Buffer data in an application database.

+

Disconnecting clears the local session. For questions about a deployment of this sample, contact that deployment's operator.

+
+
+ + diff --git a/app-client-starter-app/public/styles.css b/app-client-starter-app/public/styles.css new file mode 100644 index 0000000..07dce46 --- /dev/null +++ b/app-client-starter-app/public/styles.css @@ -0,0 +1,263 @@ +/* Colours and type follow Buffer's brand palette so this example sits + comfortably next to Buffer's own surfaces. It is not a Buffer product, so it + carries its own name and mark rather than Buffer's logo. + + Brand / Dark #283F3E Green 300 #B0EC9C Neutral 200 #F1F1E9 + Brand / Dark Alt #213130 Green 100 #D9F6CF Neutral 50 #FFFFFB + Green 800 #418829 Coral 100 #FEDBD5 + Coral 800 #A82E1E */ + +:root { + color-scheme: light dark; + + --bg: #f1f1e9; + --card: #fffffb; + --ink: #283f3e; + --muted: #5f716f; + --line: #e2e3d6; + --accent: #b0ec9c; + --accent-soft: #d9f6cf; + --on-accent: #213130; + --ok-bg: #d9f6cf; + --ok: #418829; + --err-bg: #fedbd5; + --err: #a82e1e; + --focus: #283f3e; + --radius: 16px; + --radius-sm: 10px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #213130; + --card: #283f3e; + --ink: #f1f1e9; + --muted: #a9bbb8; + --line: rgba(241, 241, 233, 0.14); + --accent: #b0ec9c; + --accent-soft: rgba(176, 236, 156, 0.16); + --on-accent: #213130; + --ok-bg: rgba(176, 236, 156, 0.16); + --ok: #b0ec9c; + --err-bg: rgba(255, 178, 168, 0.16); + --err: #ffb2a8; + --focus: #b0ec9c; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: Figtree, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, + Arial, sans-serif; + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +main { + max-width: 36rem; + margin: 0 auto; + padding: 3.5rem 1.25rem 3rem; +} + +/* The app's own mark: a queue, in Buffer's green on Buffer's dark. */ +header { + display: flex; + align-items: flex-start; + gap: 0.875rem; + margin-bottom: 2rem; +} + +.mark { + display: grid; + place-items: center; + flex: none; + width: 2.5rem; + height: 2.5rem; + border-radius: 12px; + background: var(--ink); + color: var(--accent); +} + +.mark svg { width: 1.125rem; height: 1.125rem; } + +h1 { + margin: 0.125rem 0 0.25rem; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +h2 { + margin: 0 0 0.35rem; + font-size: 1.0625rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.sub { + color: var(--muted); + margin: 0; + max-width: 34rem; +} + +section { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.75rem; +} + +section > p { margin-top: 0; } + +/* Connected state: a green dot, the account, and a quiet way out. */ +.who { + display: flex; + align-items: center; + gap: 0.625rem; + padding-bottom: 1rem; + margin-bottom: 1.375rem; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 0.9rem; +} + +.who #account { flex: 1; min-width: 0; } + +.dot { + flex: none; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +form { display: grid; gap: 1.125rem; } + +label { + display: grid; + gap: 0.4rem; + font-weight: 600; + font-size: 0.875rem; +} + +.opt { color: var(--muted); font-weight: 400; } + +select, input, textarea { + font: inherit; + color: inherit; + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0.65rem 0.75rem; + width: 100%; + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +textarea { resize: vertical; } + +select:hover, input:hover, textarea:hover { border-color: var(--muted); } + +select:focus-visible, input:focus-visible, textarea:focus-visible { + outline: none; + border-color: var(--focus); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.btn { + display: inline-block; + background: var(--ink); + color: var(--card); + border: 0; + border-radius: 999px; + padding: 0.7rem 1.4rem; + font: inherit; + font-weight: 600; + text-decoration: none; + cursor: pointer; + justify-self: start; + transition: background 120ms ease, transform 120ms ease; +} + +.btn:hover { background: #213130; } +.btn:active { transform: translateY(1px); } + +.btn:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.btn:disabled { opacity: 0.5; cursor: default; } + +@media (prefers-color-scheme: dark) { + .btn { background: var(--accent); color: var(--on-accent); } + .btn:hover { background: #c6f2b6; } + /* Green on the near-white ink has too little contrast, so the mark flips. */ + .mark { background: var(--accent); color: var(--on-accent); } +} + +.link { + background: none; + border: 0; + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: inherit; + padding: 0; + text-decoration: underline; + text-underline-offset: 3px; +} + +.link:hover { color: var(--ink); } + +.banner { + border-radius: var(--radius-sm); + padding: 0.75rem 1rem; + margin-bottom: 1.25rem; + background: var(--err-bg); + color: var(--err); + font-size: 0.9rem; +} + +.result { + margin-top: 1.375rem; + padding: 0.875rem 1rem; + border-radius: var(--radius-sm); + font-size: 0.9rem; +} + +.result.ok { background: var(--ok-bg); color: var(--ok); } +.result.bad { background: var(--err-bg); color: var(--err); } +.result a { color: inherit; } + +.result code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; + opacity: 0.8; +} + +.hint { + color: var(--muted); + font-size: 0.82rem; + margin: 1.125rem 0 0; + line-height: 1.55; +} + +/* This one sits between the account row and the form, so it needs both sides. */ +#grant { margin: 0 0 1.375rem; } + +/* Inside a field, the hint belongs with the input rather than apart from it. */ +#media-hint { margin: 0; } + + +.colophon { + margin-top: 1.75rem; + text-align: center; + color: var(--muted); + font-size: 0.78rem; +} diff --git a/app-client-starter-app/scripts/generate-cert.js b/app-client-starter-app/scripts/generate-cert.js new file mode 100644 index 0000000..ecdfcca --- /dev/null +++ b/app-client-starter-app/scripts/generate-cert.js @@ -0,0 +1,31 @@ +// Generates a self-signed certificate for https://localhost:3000. +// Buffer's registered redirect URI uses https, so the dev server needs TLS. +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +const dir = path.join(import.meta.dirname, '..', 'certs') +const key = path.join(dir, 'key.pem') +const cert = path.join(dir, 'cert.pem') + +export function ensureCert() { + if (fs.existsSync(key) && fs.existsSync(cert)) return { key, cert } + + fs.mkdirSync(dir, { recursive: true }) + const args = [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', key, '-out', cert, '-days', '825', + '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', + ] + try { + execFileSync('openssl', args, { stdio: 'pipe' }) + } catch { + // Older openssl builds reject -addext; fall back without the SAN extension. + execFileSync('openssl', args.slice(0, -2), { stdio: 'inherit' }) + } + console.log('Generated self-signed certificate in ./certs') + return { key, cert } +} + +if (process.argv[1] === import.meta.filename) ensureCert() diff --git a/app-client-starter-app/server.js b/app-client-starter-app/server.js new file mode 100644 index 0000000..4343aff --- /dev/null +++ b/app-client-starter-app/server.js @@ -0,0 +1,38 @@ +// Standalone server. +// +// Locally it serves HTTPS with a self-signed certificate, because Buffer's +// registered redirect URI uses https. Anywhere in production the platform +// terminates TLS in front of us, so it serves plain HTTP on the injected port +// and never touches the filesystem β€” a serverless filesystem is read-only. +// +// Serverless deployments route through api/index.js instead, which exports the +// same app without listening at all. +import http from 'node:http' +import https from 'node:https' +import fs from 'node:fs' +import app from './lib/app.js' +import { CLIENT_ID, CLIENT_SECRET, IS_PRODUCTION, PORT, REDIRECT_URI } from './lib/config.js' + +// Hosts terminate TLS in front of the app and hand it plain HTTP, so the same +// signal that makes a real SESSION_SECRET mandatory also decides this. Naming +// one platform here would leave the app broken on every other one. +const platformTerminatesTls = IS_PRODUCTION + +function describe(scheme) { + console.log(`\n Listening on ${scheme}://0.0.0.0:${PORT}`) + console.log(` Redirect URI: ${REDIRECT_URI}`) + console.log(` Client: ${CLIENT_ID} (${CLIENT_SECRET ? 'confidential' : 'public / PKCE only'})`) + if (scheme === 'https') console.log('\n The certificate is self-signed β€” accept the browser warning once.') + console.log() +} + +if (platformTerminatesTls) { + http.createServer(app).listen(PORT, () => describe('http')) +} else { + // Imported lazily so the certificate helper never loads in a serverless build. + const { ensureCert } = await import('./scripts/generate-cert.js') + const { key, cert } = ensureCert() + https + .createServer({ key: fs.readFileSync(key), cert: fs.readFileSync(cert) }, app) + .listen(PORT, () => describe('https')) +} diff --git a/app-client-starter-app/test/buffer.test.js b/app-client-starter-app/test/buffer.test.js new file mode 100644 index 0000000..da53c25 --- /dev/null +++ b/app-client-starter-app/test/buffer.test.js @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +process.env.BUFFER_CLIENT_ID = 'test-client' +process.env.SESSION_SECRET = 'a'.repeat(32) +process.env.REDIRECT_URI = 'https://example.test/callback' + +const { buildAuthorizeUrl, buildPostInput, createPkcePair, exchangeCodeForTokens, refreshTokens, requestTokens } = await import('../lib/buffer.js') + +test('PKCE pairs use an S256 base64url challenge', () => { + const { verifier, challenge } = createPkcePair() + assert.match(verifier, /^[A-Za-z0-9_-]{43}$/) + assert.match(challenge, /^[A-Za-z0-9_-]{43}$/) + assert.notEqual(verifier, challenge) +}) + +test('authorization requests contain the configured callback and minimum scopes', () => { + const url = new URL(buildAuthorizeUrl({ state: 'state', codeChallenge: 'challenge' })) + assert.equal(url.searchParams.get('redirect_uri'), 'https://example.test/callback') + assert.equal(url.searchParams.get('scope'), 'account:read posts:write offline_access') + assert.equal(url.searchParams.get('code_challenge_method'), 'S256') +}) + +test('post input is always draft-only and adds service metadata', () => { + const input = buildPostInput({ + channelId: 'channel-1', + service: 'instagram', + text: 'Hello', + mediaUrl: 'https://cdn.example.test/photo.jpg', + }) + assert.equal(input.saveToDraft, true) + assert.equal(input.mode, 'addToQueue') + assert.deepEqual(input.metadata, { instagram: { type: 'post', shouldShareToFeed: true } }) + assert.deepEqual(input.assets, [{ image: { url: 'https://cdn.example.test/photo.jpg' } }]) +}) + +test('token exchange keeps the refresh token in the sealed HttpOnly cookie session', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => new Response(JSON.stringify({ + access_token: 'access-token', refresh_token: 'do-not-store-me', expires_in: 3600, scope: 'account:read', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + try { + const tokens = await requestTokens({ grant_type: 'authorization_code', code: 'code' }) + assert.equal(tokens.accessToken, 'access-token') + assert.equal(tokens.refreshToken, 'do-not-store-me') + } finally { + globalThis.fetch = originalFetch + } +}) + +test('refresh replaces the old refresh token', async () => { + const originalFetch = globalThis.fetch + let body + globalThis.fetch = async (_url, options) => { + body = new URLSearchParams(options.body) + return new Response(JSON.stringify({ + access_token: 'new-access-token', refresh_token: 'new-refresh-token', expires_in: 3600, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + try { + const tokens = await refreshTokens({ + accessToken: 'old-access-token', refreshToken: 'old-refresh-token', connectedAt: 123, + }) + assert.equal(body.get('grant_type'), 'refresh_token') + assert.equal(body.get('refresh_token'), 'old-refresh-token') + assert.equal(tokens.refreshToken, 'new-refresh-token') + assert.equal(tokens.connectedAt, 123) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('the initial exchange requires a refresh token', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => new Response(JSON.stringify({ + access_token: 'access-token', expires_in: 3600, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + try { + await assert.rejects( + exchangeCodeForTokens({ code: 'code', codeVerifier: 'verifier' }), + /enable offline_access/, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('the browser code does not interpolate remote values with innerHTML', async () => { + const client = await readFile(new URL('../public/app.js', import.meta.url), 'utf8') + assert.doesNotMatch(client, /\.innerHTML\s*=/) +}) + +test('error detail reaches a developer but never a production user', async () => { + const { withCause } = await import('../lib/config.js') + assert.equal( + withCause('Could not create that draft.', 'Text too long'), + 'Could not create that draft. (Text too long)', + ) + + // IS_PRODUCTION is read once at import, so the production half needs its own + // process. Leaking an API's internals to end users is worth the extra spawn. + const { execFileSync } = await import('node:child_process') + const script = "const { withCause } = await import('./lib/config.js'); console.log(withCause('Generic.', 'secret detail'))" + const output = execFileSync(process.execPath, ['--input-type=module', '-e', script], { + env: { ...process.env, NODE_ENV: 'production', BUFFER_CLIENT_ID: 'test-client', SESSION_SECRET: 'a'.repeat(32) }, + encoding: 'utf8', + }) + assert.equal(output.trim(), 'Generic.') +}) diff --git a/app-client-starter-app/vercel.json b/app-client-starter-app/vercel.json new file mode 100644 index 0000000..a9f7877 --- /dev/null +++ b/app-client-starter-app/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "rewrites": [{ "source": "/(.*)", "destination": "/api" }] +} From 98dc7615c1177573b3ed5290b0f0ab13cf3db334 Mon Sep 17 00:00:00 2001 From: Justina Date: Thu, 24 Sep 2026 17:44:57 +0100 Subject: [PATCH 3/3] code review comments --- .../.github => .github}/workflows/ci.yml | 6 ++- app-client-starter-app/.vercelignore | 4 +- app-client-starter-app/README.md | 3 +- app-client-starter-app/lib/app.js | 40 +++++++++++++------ app-client-starter-app/lib/config.js | 33 ++++++++++++--- app-client-starter-app/test/buffer.test.js | 21 ++++++++++ 6 files changed, 86 insertions(+), 21 deletions(-) rename {app-client-starter-app/.github => .github}/workflows/ci.yml (65%) diff --git a/app-client-starter-app/.github/workflows/ci.yml b/.github/workflows/ci.yml similarity index 65% rename from app-client-starter-app/.github/workflows/ci.yml rename to .github/workflows/ci.yml index f67e25e..0d2208c 100644 --- a/app-client-starter-app/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,18 @@ permissions: contents: read jobs: - verify: + app-client-starter-app: runs-on: ubuntu-latest + defaults: + run: + working-directory: app-client-starter-app steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm + cache-dependency-path: app-client-starter-app/package-lock.json - run: npm ci - run: npm test - run: npm run lint diff --git a/app-client-starter-app/.vercelignore b/app-client-starter-app/.vercelignore index 5f0ec7f..bc72da9 100644 --- a/app-client-starter-app/.vercelignore +++ b/app-client-starter-app/.vercelignore @@ -1,4 +1,6 @@ -# Never upload the local TLS key, and let the platform install dependencies. +# Never upload local secrets (the TLS key, .env, the development session key), +# and let the platform install dependencies. certs/ node_modules/ .env +.session-key diff --git a/app-client-starter-app/README.md b/app-client-starter-app/README.md index ff751cb..8f82bfe 100644 --- a/app-client-starter-app/README.md +++ b/app-client-starter-app/README.md @@ -12,7 +12,6 @@ It's a small starter app called Draft Queue which covers key components of an ap Use it as a starting point for your own integration. Clone it, configure it with your own app client, build new features on top, or share it with your AI coding agent as a reference. -πŸ“˜ **Tutorial:** [Creating Buffer Client Apps](https://example.com/TODO-tutorial-url) walks through this example step by step, from registering an app client to deploying it. ## Prerequisites @@ -48,7 +47,7 @@ Use it as a starting point for your own integration. Clone it, configure it with | `BUFFER_CLIENT_ID` | Your app client ID from Buffer. | | `BUFFER_CLIENT_SECRET` | Required for a confidential client. Never expose it to the browser or commit it. | | `SESSION_SECRET` | At least 32 characters. Generate one with `openssl rand -hex 32`. Optional locally, required in production. | - | `REDIRECT_URI` | Optional locally. If set, it must exactly match the redirect URI registered for your app client. | + | `REDIRECT_URI` | Optional locally and on your Vercel production deployment. If set, it must exactly match the redirect URI registered for your app client. Vercel preview deployments have no default callback, so login is disabled on them unless you register the preview's `/callback` URL and set `REDIRECT_URI` for that environment. | 4. Generate a self-signed certificate for local HTTPS: diff --git a/app-client-starter-app/lib/app.js b/app-client-starter-app/lib/app.js index d44de83..4f18280 100644 --- a/app-client-starter-app/lib/app.js +++ b/app-client-starter-app/lib/app.js @@ -1,6 +1,6 @@ import express from 'express' import path from 'node:path' -import { HttpError, REDIRECT_URI, withCause } from './config.js' +import { HttpError, REDIRECT_URI, loginUnavailableReason, withCause } from './config.js' import { readSession } from './session.js' import { ACCOUNT_QUERY, @@ -46,7 +46,10 @@ const wrap = (handler) => async (req, res, next) => { } } -app.get('/auth/login', async (req, res) => { +app.get('/auth/login', wrap(async (req, res) => { + const unavailable = loginUnavailableReason(req.get('host')) + if (unavailable) return res.redirect(`/?error=${encodeURIComponent(unavailable)}`) + const { verifier, challenge } = createPkcePair() const state = createState() @@ -56,9 +59,9 @@ app.get('/auth/login', async (req, res) => { await req.session.save() res.redirect(buildAuthorizeUrl({ state, codeChallenge: challenge })) -}) +})) -app.get('/callback', async (req, res) => { +app.get('/callback', wrap(async (req, res) => { const { code, state, error, error_description: description } = req.query const pkce = req.session.pkce req.session.pkce = null // one-time values β€” discard either way @@ -84,23 +87,34 @@ app.get('/callback', async (req, res) => { return res.status(403).send('Invalid state parameter: this callback did not come from a login this app started.') } + let tokens try { // The code is short-lived, so exchange it immediately. - req.session.tokens = await exchangeCodeForTokens({ code, codeVerifier: pkce.codeVerifier }) - await req.session.save() - res.redirect('/') + tokens = await exchangeCodeForTokens({ code, codeVerifier: pkce.codeVerifier }) } catch (err) { console.error(JSON.stringify({ event: 'oauth_token_exchange_failed', code: err.oauthError, message: err.message })) await req.session.save() const message = withCause('Buffer could not complete the connection. Check your client configuration.', err.message) - res.redirect(`/?error=${encodeURIComponent(message)}`) + return res.redirect(`/?error=${encodeURIComponent(message)}`) } -}) -app.post('/auth/logout', async (req, res) => { + try { + req.session.tokens = tokens + await req.session.save() + } catch (err) { + // Saving can fail because of the tokens themselves (a sealed cookie that is + // too large, say), so drop them before saving the cleared session. + req.session.tokens = null + await req.session.save() + throw err + } + res.redirect('/') +})) + +app.post('/auth/logout', wrap(async (req, res) => { await req.session.destroy() res.json({ connected: false }) -}) +})) app.get('/api/session', wrap(async (req, res) => { // redirectUri is echoed back for debugging: curl this endpoint to see the @@ -167,7 +181,9 @@ app.post('/api/posts', wrap(async (req, res) => { // Misconfiguration (a missing SESSION_SECRET, for instance) should read as a // clear message instead of an opaque platform 500. -app.use((err, _req, res, _next) => { +app.use((err, _req, res, next) => { + // Too late for a clean response; let Express close the connection. + if (res.headersSent) return next(err) console.error(JSON.stringify({ event: 'unhandled_request_error', name: err.name, message: err.message })) res.status(500).json({ error: withCause('Something went wrong. Please try again.', err.message) }) }) diff --git a/app-client-starter-app/lib/config.js b/app-client-starter-app/lib/config.js index 3e4779d..8573e81 100644 --- a/app-client-starter-app/lib/config.js +++ b/app-client-starter-app/lib/config.js @@ -27,11 +27,17 @@ if (!CLIENT_ID) { export const CLIENT_SECRET = process.env.BUFFER_CLIENT_SECRET?.trim() || null // OAuth redirect URIs must match what is registered with Buffer exactly. -// On Vercel, default to the stable production domain β€” preview URLs change -// every deploy and would never match a registered URI. -function defaultRedirectUri() { - const productionUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL - if (productionUrl) return `https://${productionUrl}/callback` +// On Vercel, only the production deployment defaults to the stable production +// domain. Vercel sets VERCEL_PROJECT_PRODUCTION_URL on previews too, but a +// preview can't use that callback: the production host can't read the +// preview's session cookie, so the PKCE verifier would be lost. Previews get no +// default, and login stays off there unless REDIRECT_URI is set for them. +export function defaultRedirectUri(env = process.env) { + if (env.VERCEL_ENV) { + return env.VERCEL_ENV === 'production' && env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}/callback` + : null + } return `https://localhost:${PORT}/callback` } @@ -52,10 +58,27 @@ function sanitizeRedirectUri(value) { } } +// null means this deployment has no registered callback, so login is disabled. export const REDIRECT_URI = process.env.REDIRECT_URI ? sanitizeRedirectUri(process.env.REDIRECT_URI) : defaultRedirectUri() +// The session cookie holding the PKCE verifier belongs to the host that started +// the login, so the callback has to land on that same host. Returns why login +// can't start from `host`, or null when it can. +export function loginUnavailableReason(host, redirectUri = REDIRECT_URI) { + if (!redirectUri) { + return 'Login is disabled on this deployment: it has no registered callback. ' + + 'Register this deployment\'s /callback URL with your app client and set REDIRECT_URI to it, or use the production deployment.' + } + const callbackHost = new URL(redirectUri).host + if (host !== callbackHost) { + return `Login is only available at https://${callbackHost}, the host of the registered callback (${redirectUri}). ` + + 'Starting it from another host would lose the session that carries the login.' + } + return null +} + // The OAuth flow spans two requests, so a key that changed on each boot would // break any login that straddled a restart. Persist the development one. const DEV_KEY_FILE = path.join(import.meta.dirname, '..', '.session-key') diff --git a/app-client-starter-app/test/buffer.test.js b/app-client-starter-app/test/buffer.test.js index da53c25..77b85b8 100644 --- a/app-client-starter-app/test/buffer.test.js +++ b/app-client-starter-app/test/buffer.test.js @@ -6,6 +6,7 @@ process.env.BUFFER_CLIENT_ID = 'test-client' process.env.SESSION_SECRET = 'a'.repeat(32) process.env.REDIRECT_URI = 'https://example.test/callback' +const { defaultRedirectUri, loginUnavailableReason } = await import('../lib/config.js') const { buildAuthorizeUrl, buildPostInput, createPkcePair, exchangeCodeForTokens, refreshTokens, requestTokens } = await import('../lib/buffer.js') test('PKCE pairs use an S256 base64url challenge', () => { @@ -108,3 +109,23 @@ test('error detail reaches a developer but never a production user', async () => }) assert.equal(output.trim(), 'Generic.') }) + +test('only the Vercel production deployment defaults to the production callback', () => { + const productionUrl = 'app.example.test' + assert.equal( + defaultRedirectUri({ VERCEL_ENV: 'production', VERCEL_PROJECT_PRODUCTION_URL: productionUrl }), + 'https://app.example.test/callback', + ) + // Vercel sets the production URL on previews too; it must not be used there. + assert.equal(defaultRedirectUri({ VERCEL_ENV: 'preview', VERCEL_PROJECT_PRODUCTION_URL: productionUrl }), null) + assert.equal(defaultRedirectUri({ VERCEL_ENV: 'development', VERCEL_PROJECT_PRODUCTION_URL: productionUrl }), null) + assert.equal(defaultRedirectUri({}), 'https://localhost:3000/callback') +}) + +test('login starts only on the host of the registered callback', () => { + assert.equal(loginUnavailableReason('example.test', 'https://example.test/callback'), null) + assert.equal(loginUnavailableReason('localhost:3000', 'https://localhost:3000/callback'), null) + assert.match(loginUnavailableReason('app-git-branch.vercel.app', 'https://example.test/callback'), /only available at https:\/\/example\.test/) + assert.match(loginUnavailableReason('127.0.0.1:3000', 'https://localhost:3000/callback'), /only available/) + assert.match(loginUnavailableReason('app-git-branch.vercel.app', null), /disabled on this deployment/) +})