[object Object]

← 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

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 →