Repository navigation
add a sample app #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| pull_request: | ||
| push: | ||
| branches: [main] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| 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 | ||
|
Comment on lines
+22
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: ls -la .github .github/workflows 2>/dev/null; ls -la; ls app-client-starter-app
cat app-client-starter-app/.github/workflows/ci.yml
grep -n -i 'clone\|cd \|git \|npm ci\|npm install\|template\|standalone' app-client-starter-app/README.md app-client-starter-app/CONTRIBUTING.mdRepository: bufferapp/buffer-sample-apps Length of output: 1447 🏁 Script executed: printf '%s\n' '--- tracked workflow files ---'
git ls-files '*workflow*' '*workflows*' | sort
printf '%s\n' '--- root README ---'
cat -n README.md
printf '%s\n' '--- app README ---'
cat -n app-client-starter-app/README.md
printf '%s\n' '--- app CONTRIBUTING ---'
cat -n app-client-starter-app/CONTRIBUTING.md
printf '%s\n' '--- root package metadata ---'
cat -n package.json | head -80Repository: bufferapp/buffer-sample-apps Length of output: 8820 Move the workflow to the repository root. GitHub Actions does not discover workflows under Suggested fix+ Move app-client-starter-app/.github/workflows/ci.yml to .github/workflows/ci.yml
with:
node-version: 22
cache: npm
- - run: npm ci
+ cache-dependency-path: app-client-starter-app/package-lock.json
+ - run: npm ci
+ working-directory: app-client-starter-app
- run: npm test
+ working-directory: app-client-starter-app
- run: npm run lint
+ working-directory: app-client-starter-app🤖 Prompt for AI Agents |
||
| - run: npm test | ||
| - run: npm run lint | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| node_modules/ | ||
| certs/ | ||
| .env | ||
| .session-key |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| # Never upload local secrets (the TLS key, .env, the development session key), | ||
| # and let the platform install dependencies. | ||
| certs/ | ||
| node_modules/ | ||
| .env | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| .session-key | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| # 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. | ||
|
|
||
|
|
||
| ## 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 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: | ||
|
|
||
| ```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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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' |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,191 @@ | ||||||||||||||||||||||||||||||||
| import express from 'express' | ||||||||||||||||||||||||||||||||
| import path from 'node:path' | ||||||||||||||||||||||||||||||||
| import { HttpError, REDIRECT_URI, loginUnavailableReason, 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', 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() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // 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', 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 | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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.') | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| let tokens | ||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||
| // The code is short-lived, so exchange it immediately. | ||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||
| return res.redirect(`/?error=${encodeURIComponent(message)}`) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||
| // 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') | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+149
to
+154
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Return 400 for non-string or malformed post input, not 500. The validation does not check input types or URL structure:
In both cases the error has no 🐛 Proposed fix- if (!text?.trim()) throw new HttpError(400, 'text is required')
+ if (typeof text !== 'string' || !text.trim()) throw new HttpError(400, 'text is required')
+ if (mediaUrl != null && typeof mediaUrl !== 'string') throw new HttpError(400, 'mediaUrl must be a string')
// 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')
+ if (mediaUrl?.trim()) {
+ let parsed
+ try { parsed = new URL(mediaUrl.trim()) } catch { parsed = null }
+ if (parsed?.protocol !== 'https:') throw new HttpError(400, 'mediaUrl must be a public https URL')
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // `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) => { | ||||||||||||||||||||||||||||||||
| // 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) }) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export default app | ||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Disable checkout credential persistence for this PR job.
actions/checkout@v4persists the token in local Git configuration by default. Pull-request code can run during installation and testing, then read and exfiltrate that token. The workflow limits the token tocontents: read, but this job does not need authenticated Git commands. Setpersist-credentials: falseon the checkout step. (github.com)🤖 Prompt for AI Agents
Source: Linters/SAST tools