Skip to content

Commit 428ba16

Browse files
ralyodioclaude
andauthored
feat(cli): authenticated coupon + bounty submission from the CLI (#33)
Adds a device-style login (login URL + paste code) and write commands to the shipped bash CLI, plus the backend support for token auth. Backend: - getSessionDid() now also accepts the signed session token via `Authorization: Bearer <token>` (browser still uses the httpOnly cookie). This lets the CLI authenticate as the user. - New /cli-auth page: after CoinPay OAuth it shows the user's token to copy-paste into the CLI; otherwise it offers "Connect with CoinPay". noindex. CLI (apps/web/public/cli/c0upons, v1.0.0 → v1.1.0): - `login` — prints WEB/cli-auth, reads pasted token, verifies via /api/auth/me, saves to $XDG_CONFIG_HOME/c0upons/token (chmod 600). - `logout` — forgets the token. - `submit` — post a coupon (--title/--store/--code/--percent|--off/ --url/--description/--expiry). - `bounty` — post a bounty (--title/--store/--reward/--url/--desc); prints the bounty URL + CoinPay funding link. - Payloads built with `jq -n` for safe escaping; authed POSTs send the bearer token; clear 401 handling ("run c0upons login"). Verified: bearer auth (200 with token / 401 without), /cli-auth render, and real coupon+bounty inserts via bearer (test rows cleaned up). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 797f994 commit 428ba16

4 files changed

Lines changed: 257 additions & 5 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
5+
export default function CopyToken({ token }: { token: string }) {
6+
const [copied, setCopied] = useState(false);
7+
8+
const handleCopy = async () => {
9+
try {
10+
await navigator.clipboard.writeText(token);
11+
setCopied(true);
12+
setTimeout(() => setCopied(false), 2000);
13+
} catch {
14+
/* clipboard may be unavailable; user can still select the text */
15+
}
16+
};
17+
18+
return (
19+
<div className="flex flex-col gap-3">
20+
<pre className="bg-gray-900 text-green-300 text-xs rounded-xl p-4 overflow-x-auto whitespace-pre-wrap break-all select-all">
21+
{token}
22+
</pre>
23+
<button
24+
onClick={handleCopy}
25+
className={`self-start inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${
26+
copied ? 'bg-green-500 text-white' : 'bg-gray-900 hover:bg-gray-700 text-white'
27+
}`}
28+
>
29+
{copied ? 'Copied ✓' : 'Copy code'}
30+
</button>
31+
</div>
32+
);
33+
}

apps/web/app/cli-auth/page.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export const dynamic = 'force-dynamic';
2+
3+
import type { Metadata } from 'next';
4+
import { cookies } from 'next/headers';
5+
import { COOKIE, parseSession } from '@/lib/auth';
6+
import CopyToken from './CopyToken';
7+
8+
export const metadata: Metadata = {
9+
title: 'CLI Login',
10+
robots: { index: false, follow: false },
11+
};
12+
13+
export default async function CliAuthPage() {
14+
const jar = await cookies();
15+
const token = jar.get(COOKIE)?.value ?? null;
16+
const did = token ? await parseSession(token) : null;
17+
18+
return (
19+
<div className="max-w-lg mx-auto py-12">
20+
<h1 className="text-3xl font-black text-gray-900 mb-2">Connect the CLI</h1>
21+
22+
{did && token ? (
23+
<>
24+
<p className="text-gray-500 mb-6 text-sm">
25+
You&apos;re signed in as <span className="font-mono text-gray-700">{did.slice(0, 20)}</span>.
26+
Copy the code below and paste it back into your terminal.
27+
</p>
28+
<CopyToken token={token} />
29+
<p className="text-xs text-gray-400 mt-6">
30+
Treat this code like a password — it grants access to post coupons and bounties as you for
31+
30 days. Run <code className="font-mono">c0upons logout</code> on your machine to forget it.
32+
</p>
33+
</>
34+
) : (
35+
<>
36+
<p className="text-gray-500 mb-6 text-sm">
37+
Sign in with CoinPay, then you&apos;ll get a code to paste into the CLI.
38+
</p>
39+
<a
40+
href="/api/auth/coinpay?returnTo=/cli-auth"
41+
className="inline-flex items-center gap-2 bg-gray-900 hover:bg-gray-700 text-white font-semibold px-6 py-3 rounded-xl transition-colors"
42+
>
43+
Connect with CoinPay
44+
</a>
45+
</>
46+
)}
47+
</div>
48+
);
49+
}

apps/web/lib/auth.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import 'server-only';
2-
import { cookies } from 'next/headers';
2+
import { cookies, headers } from 'next/headers';
33

44
const COOKIE = 'cp_session';
55
const enc = new TextEncoder();
@@ -45,6 +45,14 @@ export async function parseSession(value: string): Promise<string | null> {
4545

4646
export async function getSessionDid(): Promise<string | null> {
4747
try {
48+
// CLI / API clients authenticate with the same signed session token sent as
49+
// `Authorization: Bearer <token>`. Browser requests use the httpOnly cookie.
50+
const hdrs = await headers();
51+
const auth = hdrs.get('authorization');
52+
if (auth?.startsWith('Bearer ')) {
53+
const did = await parseSession(auth.slice(7).trim());
54+
if (did) return did;
55+
}
4856
const jar = await cookies();
4957
const val = jar.get(COOKIE)?.value;
5058
if (!val) return null;

apps/web/public/cli/c0upons

Lines changed: 166 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@
66

77
set -euo pipefail
88

9-
VERSION="1.0.0"
9+
VERSION="1.1.0"
1010
BASE_URL="${C0UPONS_API:-https://c0upons.com/api}"
11+
# Website root (for login + share links). Defaults to BASE_URL without /api.
12+
WEB_URL="${C0UPONS_WEB:-${BASE_URL%/api}}"
13+
14+
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/c0upons"
15+
TOKEN_FILE="${CONFIG_DIR}/token"
1116

1217
BOLD="\033[1m"
1318
DIM="\033[2m"
@@ -27,16 +32,20 @@ usage() {
2732
echo " latest Show latest/trending coupons"
2833
echo " stores List all stores"
2934
echo " store <slug> Show coupons for a store"
35+
echo " login Connect your CoinPay account"
36+
echo " logout Forget saved credentials"
37+
echo " submit Submit a coupon (requires login)"
38+
echo " bounty Post a coupon bounty (requires login)"
3039
echo " upgrade Upgrade to the latest version (alias: update)"
3140
echo " remove Uninstall the CLI (alias: uninstall)"
3241
echo " version Print version"
3342
echo " help Show this help"
3443
echo ""
3544
echo -e "${BOLD}EXAMPLES${RESET}"
3645
echo " c0upons search nike"
37-
echo " c0upons latest"
38-
echo " c0upons stores"
39-
echo " c0upons store adidas"
46+
echo " c0upons login"
47+
echo " c0upons submit --store Nike --title '20% off sitewide' --code SAVE20 --percent 20 --url https://nike.com"
48+
echo " c0upons bounty --store Adidas --title 'Need a 30% off code' --reward 1.00"
4049
echo ""
4150
echo -e "${DIM}Override API: export C0UPONS_API=http://localhost:3000/api${RESET}"
4251
echo -e "${DIM}Docs: https://c0upons.com/docs${RESET}"
@@ -52,6 +61,35 @@ require_jq() {
5261
fi
5362
}
5463

64+
require_auth() {
65+
if [ ! -f "$TOKEN_FILE" ] || [ ! -s "$TOKEN_FILE" ]; then
66+
echo -e "${RED}Not logged in.${RESET} Run: ${BOLD}c0upons login${RESET}"
67+
exit 1
68+
fi
69+
TOKEN=$(cat "$TOKEN_FILE")
70+
}
71+
72+
# api_post <path> <json-payload> — sends an authenticated POST.
73+
# On success, leaves the response body in $API_BODY. On failure, prints the
74+
# error and exits.
75+
api_post() {
76+
local path="$1" payload="$2" resp code
77+
resp=$(curl -sS -w $'\n%{http_code}' -X POST "${BASE_URL}${path}" \
78+
-H "Content-Type: application/json" \
79+
-H "Authorization: Bearer ${TOKEN}" \
80+
-d "$payload") || { echo -e "${RED}Network error.${RESET} Could not reach ${BASE_URL}."; exit 1; }
81+
code=$(printf '%s' "$resp" | tail -n1)
82+
API_BODY=$(printf '%s' "$resp" | sed '$d')
83+
if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
84+
return 0
85+
fi
86+
local msg
87+
msg=$(printf '%s' "$API_BODY" | jq -r '.error // empty' 2>/dev/null || true)
88+
echo -e "${RED}Error (${code}):${RESET} ${msg:-request failed}"
89+
[ "$code" = "401" ] && echo -e " Run: ${BOLD}c0upons login${RESET}"
90+
exit 1
91+
}
92+
5593
urlencode() {
5694
local raw="$1"
5795
if command -v python3 &>/dev/null; then
@@ -156,6 +194,126 @@ cmd_store() {
156194
done <<< "$coupons"
157195
}
158196

197+
cmd_login() {
198+
require_jq
199+
echo -e "To connect the CLI, open this URL and sign in with CoinPay:"
200+
echo ""
201+
echo -e " ${BOLD}${WEB_URL}/cli-auth${RESET}"
202+
echo ""
203+
printf "Paste the code here: "
204+
local token
205+
read -r token
206+
token=$(printf '%s' "$token" | tr -d '[:space:]')
207+
if [ -z "$token" ]; then
208+
echo -e "${RED}No code entered.${RESET}"
209+
exit 1
210+
fi
211+
local did
212+
did=$(curl -fsSL -H "Authorization: Bearer ${token}" "${BASE_URL}/auth/me" 2>/dev/null | jq -r '.did // empty') || true
213+
if [ -z "$did" ]; then
214+
echo -e "${RED}That code didn't work.${RESET} Make sure you copied the whole thing."
215+
exit 1
216+
fi
217+
mkdir -p "$CONFIG_DIR"
218+
printf '%s' "$token" > "$TOKEN_FILE"
219+
chmod 600 "$TOKEN_FILE"
220+
echo -e "${GREEN}${RESET} Logged in as ${BOLD}${did}${RESET}"
221+
}
222+
223+
cmd_logout() {
224+
if [ -f "$TOKEN_FILE" ]; then
225+
rm -f "$TOKEN_FILE"
226+
echo -e "${GREEN}${RESET} Logged out."
227+
else
228+
echo "Not logged in."
229+
fi
230+
}
231+
232+
cmd_submit() {
233+
require_jq
234+
require_auth
235+
local title="" store="" code="" url="" website="" description="" expiry="" dtype="" dvalue=""
236+
while [ $# -gt 0 ]; do
237+
case "$1" in
238+
--title) title="${2:-}"; shift 2 ;;
239+
--store) store="${2:-}"; shift 2 ;;
240+
--code) code="${2:-}"; shift 2 ;;
241+
--url) url="${2:-}"; shift 2 ;;
242+
--website) website="${2:-}"; shift 2 ;;
243+
--description|--desc) description="${2:-}"; shift 2 ;;
244+
--expiry) expiry="${2:-}"; shift 2 ;;
245+
--percent) dtype="percent"; dvalue="${2:-}"; shift 2 ;;
246+
--off) dtype="fixed"; dvalue="${2:-}"; shift 2 ;;
247+
*) echo -e "${RED}Unknown option:${RESET} $1"; exit 1 ;;
248+
esac
249+
done
250+
if [ -z "$title" ]; then
251+
echo "Usage: c0upons submit --title <title> [--store <name>] [--code <code>]"
252+
echo " [--percent <n> | --off <n>] [--url <url>]"
253+
echo " [--description <d>] [--expiry YYYY-MM-DD]"
254+
exit 1
255+
fi
256+
local payload
257+
payload=$(jq -n \
258+
--arg title "$title" --arg store "$store" --arg code "$code" --arg url "$url" \
259+
--arg website "$website" --arg description "$description" --arg expiry "$expiry" \
260+
--arg dtype "$dtype" --arg dvalue "$dvalue" \
261+
'{
262+
title: $title,
263+
store_name: (if $store == "" then null else $store end),
264+
code: (if $code == "" then null else $code end),
265+
url: (if $url == "" then null else $url end),
266+
store_website: (if $website == "" then null else $website end),
267+
description: (if $description == "" then null else $description end),
268+
expiry_date: (if $expiry == "" then null else $expiry end),
269+
discount_type: (if $dtype == "" then null else $dtype end),
270+
discount_value: (if $dvalue == "" then null else $dvalue end)
271+
}')
272+
api_post "/coupons" "$payload"
273+
echo -e "${GREEN}${RESET} Coupon submitted!"
274+
}
275+
276+
cmd_bounty() {
277+
require_jq
278+
require_auth
279+
local title="" store="" reward="" url="" description=""
280+
while [ $# -gt 0 ]; do
281+
case "$1" in
282+
--title) title="${2:-}"; shift 2 ;;
283+
--store) store="${2:-}"; shift 2 ;;
284+
--reward) reward="${2:-}"; shift 2 ;;
285+
--url) url="${2:-}"; shift 2 ;;
286+
--description|--desc) description="${2:-}"; shift 2 ;;
287+
*) echo -e "${RED}Unknown option:${RESET} $1"; exit 1 ;;
288+
esac
289+
done
290+
if [ -z "$title" ] || [ -z "$store" ] || [ -z "$reward" ]; then
291+
echo "Usage: c0upons bounty --title <title> --store <name> --reward <usd>"
292+
echo " [--url <url>] [--description <d>]"
293+
exit 1
294+
fi
295+
local payload
296+
payload=$(jq -n \
297+
--arg title "$title" --arg store "$store" --arg reward "$reward" \
298+
--arg url "$url" --arg description "$description" \
299+
'{
300+
title: $title,
301+
store_name: $store,
302+
reward_usd: $reward,
303+
url: (if $url == "" then null else $url end),
304+
description: (if $description == "" then null else $description end)
305+
}')
306+
api_post "/bounties" "$payload"
307+
local pubid pay_url
308+
pubid=$(printf '%s' "$API_BODY" | jq -r '.public_id // .id // empty')
309+
pay_url=$(printf '%s' "$API_BODY" | jq -r '.pay_url // empty')
310+
echo -e "${GREEN}${RESET} Bounty posted!"
311+
[ -n "$pubid" ] && echo -e " ${DIM}${WEB_URL}/bounties/${pubid}${RESET}"
312+
if [ -n "$pay_url" ]; then
313+
echo -e " Fund it to make it live: ${BOLD}${pay_url}${RESET}"
314+
fi
315+
}
316+
159317
cmd_upgrade() {
160318
local self
161319
self="$(command -v c0upons 2>/dev/null || echo "")"
@@ -206,6 +364,10 @@ case "${1:-help}" in
206364
latest) cmd_latest ;;
207365
stores) cmd_stores ;;
208366
store) shift; cmd_store "$@" ;;
367+
login) cmd_login ;;
368+
logout) cmd_logout ;;
369+
submit) shift; cmd_submit "$@" ;;
370+
bounty) shift; cmd_bounty "$@" ;;
209371
upgrade|update) cmd_upgrade ;;
210372
remove|uninstall) cmd_remove ;;
211373
version|--version|-v) echo "c0upons v${VERSION}" ;;

0 commit comments

Comments
 (0)