← back to Wallco Ai
etsy oauth: scripts/etsy-oauth-helper.js — one-time interactive auth (PKCE/OAuth2, local 3008 redirect listener, auto-opens browser, captures code, exchanges for tokens, fetches shop_id via /users/{id}/shops, prints secrets-manager save command). --refresh mode for the 1-hour access-token expiry. Fail-fast on missing ETSY_CLIENT_ID
7cdc0dfa8a5cf8888a702cce3e4f5c924541e585 · 2026-05-28 07:01:16 -0700 · Steve Abrams
Files touched
A scripts/etsy-oauth-helper.js
Diff
commit 7cdc0dfa8a5cf8888a702cce3e4f5c924541e585
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 07:01:16 2026 -0700
etsy oauth: scripts/etsy-oauth-helper.js — one-time interactive auth (PKCE/OAuth2, local 3008 redirect listener, auto-opens browser, captures code, exchanges for tokens, fetches shop_id via /users/{id}/shops, prints secrets-manager save command). --refresh mode for the 1-hour access-token expiry. Fail-fast on missing ETSY_CLIENT_ID
---
scripts/etsy-oauth-helper.js | 266 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 266 insertions(+)
diff --git a/scripts/etsy-oauth-helper.js b/scripts/etsy-oauth-helper.js
new file mode 100644
index 0000000..0f0e7db
--- /dev/null
+++ b/scripts/etsy-oauth-helper.js
@@ -0,0 +1,266 @@
+#!/usr/bin/env node
+/**
+ * etsy-oauth-helper — one-time OAuth2/PKCE dance to get a seller access +
+ * refresh token for the Etsy v3 Open API. Also handles refresh-token
+ * exchange for production (Etsy access tokens expire in 1 hour).
+ *
+ * Etsy v3 only supports OAuth2 with PKCE (no client_secret on the back
+ * channel). Required scopes for digital-download listing creation:
+ *
+ * listings_w — create + modify listings
+ * listings_r — read listings
+ * shops_r — read shop info (to discover shop_id)
+ * email_r — minimal user info (required for /users/me lookup)
+ *
+ * Two modes:
+ *
+ * node scripts/etsy-oauth-helper.js # interactive auth flow
+ * node scripts/etsy-oauth-helper.js --refresh # refresh expired access token
+ *
+ * Interactive flow:
+ * 1. Reads ETSY_CLIENT_ID from env (or --client-id <key>)
+ * 2. Spins up a local HTTP server on port 3008 (or --port N)
+ * 3. Prints + opens the Etsy authorize URL in your browser
+ * 4. You log in to Etsy + click Allow
+ * 5. Etsy redirects to localhost — we catch the code
+ * 6. Exchange code → access + refresh token
+ * 7. Fetch /users/me + /users/{user_id}/shops → discover shop_id
+ * 8. Print everything + the exact secrets-manager command to save it
+ *
+ * IMPORTANT — set the redirect URL in your Etsy app to EXACTLY:
+ * http://localhost:3008/etsy-oauth-callback
+ * (Etsy lets you set multiple — leave the prod one in place and add this.)
+ *
+ * One-time setup at https://www.etsy.com/developers/your-apps:
+ * 1. Create an app → grab the "Keystring" (= ETSY_CLIENT_ID)
+ * 2. Add http://localhost:3008/etsy-oauth-callback as a Callback URL
+ * 3. Save, then run this script
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const { execSync, spawn } = require('child_process');
+
+const argv = process.argv.slice(2);
+const ARG = (k, d) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : d; };
+const HAS = k => argv.includes(k);
+const opt = {
+ port: parseInt(ARG('--port', '3008'), 10),
+ refresh: HAS('--refresh'),
+ clientId: ARG('--client-id'),
+ refreshTok: ARG('--refresh-token'),
+ scopes: ARG('--scopes', 'listings_w listings_r shops_r email_r'),
+ noOpen: HAS('--no-open'),
+};
+
+// Auto-load creds from secrets-manager .env (same pattern as push-etsy-bundle).
+function loadSecretsEnv() {
+ const p = path.join(require('os').homedir(), 'Projects', 'secrets-manager', '.env');
+ if (!fs.existsSync(p)) return;
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
+ }
+}
+loadSecretsEnv();
+
+const CLIENT_ID = opt.clientId || process.env.ETSY_CLIENT_ID;
+const REDIRECT_URI = `http://localhost:${opt.port}/etsy-oauth-callback`;
+
+if (!CLIENT_ID) {
+ console.error('Missing ETSY_CLIENT_ID. Either:');
+ console.error(' • set it in ~/Projects/secrets-manager/.env, OR');
+ console.error(' • pass --client-id <your-app-keystring>');
+ console.error('Get one at https://www.etsy.com/developers/your-apps');
+ process.exit(2);
+}
+
+// ── HTTP helpers ────────────────────────────────────────────────────────────
+const https = require('https');
+function httpsRequest(method, urlStr, headers, body) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(urlStr);
+ const req = https.request({
+ method, hostname: u.hostname, path: u.pathname + u.search,
+ headers: { Accept: 'application/json', ...headers },
+ }, res => {
+ let data = '';
+ res.on('data', c => data += c);
+ res.on('end', () => {
+ const ok = res.statusCode >= 200 && res.statusCode < 300;
+ try {
+ const j = data ? JSON.parse(data) : {};
+ if (!ok) return reject(new Error(`${method} ${u.pathname} → ${res.statusCode}: ${j.error || data.slice(0, 400)}`));
+ resolve(j);
+ } catch { ok ? resolve({ raw: data }) : reject(new Error(`${method} ${u.pathname} → ${res.statusCode}: ${data.slice(0, 400)}`)); }
+ });
+ });
+ req.on('error', reject);
+ if (body) req.write(body);
+ req.end();
+ });
+}
+
+// ── refresh-only mode ───────────────────────────────────────────────────────
+async function refreshMode() {
+ const rt = opt.refreshTok || process.env.ETSY_OAUTH_REFRESH_TOKEN;
+ if (!rt) {
+ console.error('No refresh token. Either pass --refresh-token <token> or set ETSY_OAUTH_REFRESH_TOKEN in secrets-manager.');
+ process.exit(2);
+ }
+ console.log('Exchanging refresh token for a fresh access token…');
+ const body = new URLSearchParams({ grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: rt }).toString();
+ const tok = await httpsRequest('POST', 'https://api.etsy.com/v3/public/oauth/token',
+ { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': body.length }, body);
+ printTokens(tok, /* alsoFetchShop */ false);
+}
+
+// ── interactive auth ────────────────────────────────────────────────────────
+function generatePKCE() {
+ const verifier = crypto.randomBytes(48).toString('base64url');
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ return { verifier, challenge };
+}
+
+async function authorize() {
+ const { verifier, challenge } = generatePKCE();
+ const state = crypto.randomBytes(12).toString('base64url');
+ const authUrl = 'https://www.etsy.com/oauth/connect?' + new URLSearchParams({
+ response_type: 'code',
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT_URI,
+ scope: opt.scopes,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ }).toString();
+
+ console.log('\n══ Etsy OAuth2 — interactive auth ══');
+ console.log(` client_id: ${CLIENT_ID.slice(0, 8)}…${CLIENT_ID.slice(-4)}`);
+ console.log(` redirect: ${REDIRECT_URI}`);
+ console.log(` scopes: ${opt.scopes}`);
+ console.log('\n This URL is opening in your browser — log in to Etsy + click "Allow access":');
+ console.log(` ${authUrl.slice(0, 110)}…\n`);
+
+ // Start local listener BEFORE opening browser so we never miss the callback
+ const codePromise = new Promise((resolve, reject) => {
+ const server = http.createServer((req, res) => {
+ const u = new URL(req.url, `http://localhost:${opt.port}`);
+ if (u.pathname !== '/etsy-oauth-callback') {
+ res.writeHead(404).end('Not found');
+ return;
+ }
+ const code = u.searchParams.get('code');
+ const gotState = u.searchParams.get('state');
+ const err = u.searchParams.get('error');
+ if (err) {
+ res.writeHead(400, { 'Content-Type': 'text/html' }).end(`<h1>Etsy denied: ${err}</h1><p>You can close this tab.</p>`);
+ server.close();
+ return reject(new Error(`Etsy denied: ${err}`));
+ }
+ if (gotState !== state) {
+ res.writeHead(400, { 'Content-Type': 'text/html' }).end(`<h1>State mismatch — possible CSRF.</h1>`);
+ server.close();
+ return reject(new Error('state mismatch'));
+ }
+ res.writeHead(200, { 'Content-Type': 'text/html' }).end(
+ `<!doctype html><body style="font:14px -apple-system,sans-serif;padding:40px;background:#0e0f10;color:#e9ecef">
+ <h1 style="color:#7bc96f">✓ Etsy connected</h1>
+ <p>You can close this tab and return to your terminal.</p>
+ </body>`
+ );
+ server.close();
+ resolve(code);
+ });
+ server.listen(opt.port, '127.0.0.1', () => {
+ console.log(` listening on ${REDIRECT_URI}… (waiting for Etsy to redirect back)`);
+ });
+ server.on('error', reject);
+ setTimeout(() => { server.close(); reject(new Error('timeout — Etsy did not redirect within 5 min')); }, 5 * 60 * 1000);
+ });
+
+ // Open browser (macOS `open`, Linux `xdg-open`, Windows `start`)
+ if (!opt.noOpen) {
+ const platform = process.platform;
+ const opener = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
+ try { spawn(opener, [authUrl], { detached: true, stdio: 'ignore' }).unref(); } catch {}
+ }
+
+ const code = await codePromise;
+ console.log('\n ✓ got authorization code');
+
+ // Exchange code → tokens
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT_URI,
+ code,
+ code_verifier: verifier,
+ }).toString();
+ console.log(' exchanging for access + refresh tokens…');
+ const tok = await httpsRequest('POST', 'https://api.etsy.com/v3/public/oauth/token',
+ { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': body.length }, body);
+ await printTokens(tok, /* alsoFetchShop */ true);
+}
+
+// ── token output + shop_id discovery + secrets-manager hint ────────────────
+async function printTokens(tok, alsoFetchShop) {
+ // Etsy returns: { access_token, token_type, expires_in, refresh_token }
+ // access_token's first segment (before '.') is the user_id — handy for /users/me skip.
+ const userId = (tok.access_token || '').split('.')[0];
+ console.log('\n══ Tokens received ══');
+ console.log(` access_token : ${tok.access_token}`);
+ console.log(` refresh_token : ${tok.refresh_token || '(no new refresh_token — keep your existing one)'}`);
+ console.log(` expires_in : ${tok.expires_in} seconds (~${Math.round(tok.expires_in / 60)} min)`);
+ console.log(` user_id : ${userId || '(parse failed)'}`);
+
+ let shopId = null;
+ if (alsoFetchShop && userId) {
+ try {
+ console.log('\n discovering shop_id…');
+ const shopInfo = await httpsRequest('GET', `https://openapi.etsy.com/v3/application/users/${userId}/shops`,
+ { 'x-api-key': CLIENT_ID, Authorization: `Bearer ${tok.access_token}` });
+ // Some accounts return a single object; others wrap in {shop_id, …} or {results:[…]}.
+ shopId = shopInfo.shop_id || shopInfo.results?.[0]?.shop_id || shopInfo[0]?.shop_id;
+ const shopName = shopInfo.shop_name || shopInfo.results?.[0]?.shop_name || '(no name returned)';
+ console.log(` ✓ shop_id : ${shopId || '(not found — pass --shop-id manually)'} · ${shopName}`);
+ } catch (e) {
+ console.log(` ⚠ shop lookup failed: ${e.message}`);
+ console.log(' (you can run this manually after auth: GET /v3/application/users/{user_id}/shops)');
+ }
+ }
+
+ // Save command
+ console.log('\n══ Save these via secrets-manager ══');
+ const parts = [
+ `ETSY_CLIENT_ID=${CLIENT_ID}`,
+ `ETSY_OAUTH_ACCESS_TOKEN=${tok.access_token}`,
+ tok.refresh_token ? `ETSY_OAUTH_REFRESH_TOKEN=${tok.refresh_token}` : null,
+ shopId ? `ETSY_SHOP_ID=${shopId}` : null,
+ ].filter(Boolean);
+ console.log(` node ~/Projects/secrets-manager/cli.js add \\\n ${parts.join(' \\\n ')}`);
+ console.log('\n (the secrets skill will route to ~/Projects/secrets-manager/.env + every project .env');
+ console.log(' listed in routes.json. push-etsy-bundle.js reads from secrets-manager .env on launch.)');
+
+ console.log('\n══ Next ══');
+ console.log(' Test connectivity:');
+ console.log(` curl -H "x-api-key: ${CLIENT_ID}" -H "Authorization: Bearer ${tok.access_token.slice(0, 16)}…" \\`);
+ console.log(` https://openapi.etsy.com/v3/application/users/me | jq .`);
+ console.log(' Push your first bundle:');
+ console.log(' node scripts/push-etsy-bundle.js --slug <bundle-slug> --dry-run');
+ console.log(' node scripts/push-etsy-bundle.js --slug <bundle-slug>');
+ console.log('\n Access tokens expire in 1 hour. To refresh:');
+ console.log(' node scripts/etsy-oauth-helper.js --refresh');
+}
+
+// ── main ────────────────────────────────────────────────────────────────────
+(async () => {
+ try {
+ if (opt.refresh) await refreshMode();
+ else await authorize();
+ } catch (e) {
+ console.error('\n✗ ' + e.message);
+ process.exit(1);
+ }
+})();
← 3ce6882 cactus-curator by-base layout: tight grid like the index pag
·
back to Wallco Ai
·
cactus-curator by-base: 'Published only' filter (default ON) 78f5533 →