Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions packages/extension/public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
{
"manifest_version": 2,
"manifest_version": 3,
"name": "ShopConnect",
"version": "0.1.0",
"description": "Project description",
"description": "Polygon ID zero-knowledge wallet that unlocks private, proof-of-purchase promotions on supported stores.",
"icons": {
"16": "icons/icon_16.png",
"32": "icons/icon_32.png",
"48": "icons/icon_48.png",
"128": "icons/icon_128.png"
},
"background": {
"scripts": ["scripts/background.js"],
"persistent": false
"service_worker": "scripts/background.js"
},
"content_scripts": [
{
Expand All @@ -20,11 +19,19 @@
"js": ["scripts/contentScript.js"]
}
],
"browser_action": {
"action": {
"default_title": "ShopConnect",
"default_popup": "index.html"
},
"permissions": ["storage", "tabs", "activeTab"],
"web_accessible_resources": ["index.html"],
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self' data:; worker-src 'self' data:"
"host_permissions": ["<all_urls>"],
"web_accessible_resources": [
{
"resources": ["index.html"],
"matches": ["<all_urls>"]
}
],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
1 change: 1 addition & 0 deletions packages/store/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.vercel
13 changes: 10 additions & 3 deletions packages/store/app/api/shopconnect/issue/route.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
const API = process.env.SHOPCONNECT_API_URL || 'http://localhost:3001';
// Called at checkout: issues a ShopPurchase credential (proof of purchase) to the shopper's
// DID. Uses the self-contained demo backend by default; set SHOPCONNECT_API_URL to delegate
// to a real ShopConnect API.
import { issueProofOfPurchase } from '@/lib/shopconnect-demo';

const API = process.env.SHOPCONNECT_API_URL;

// Called at checkout: asks the ShopConnect API to issue a ShopPurchase credential
// (proof of purchase) to the shopper's DID.
export async function POST(request) {
const body = await request.json();
if (!API) {
const result = issueProofOfPurchase(body);
return Response.json(result, { status: result.issued ? 200 : 400 });
}
try {
const res = await fetch(`${API}/issue`, {
method: 'POST',
Expand Down
10 changes: 7 additions & 3 deletions packages/store/app/api/shopconnect/promotions/route.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
// ShopConnect backend plugin endpoint: proxies the store's promotions request to the
// ShopConnect API. Keeping it server-side means the API URL/keys never reach the browser.
const API = process.env.SHOPCONNECT_API_URL || 'http://localhost:3001';
// ShopConnect backend plugin endpoint. By default it serves the self-contained demo
// backend (lib/shopconnect-demo) so the store runs as a single deployable app. Set
// SHOPCONNECT_API_URL to proxy to a real ShopConnect API instead (URL/keys stay server-side).
import { getPromotions } from '@/lib/shopconnect-demo';

const API = process.env.SHOPCONNECT_API_URL;

export async function GET(request) {
const site = new URL(request.url).searchParams.get('site') || 'demo-store';
if (!API) return Response.json({ promotions: getPromotions(site) });
try {
const res = await fetch(`${API}/promotions?site=${encodeURIComponent(site)}`, {
cache: 'no-store',
Expand Down
10 changes: 9 additions & 1 deletion packages/store/app/api/shopconnect/verify/route.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
const API = process.env.SHOPCONNECT_API_URL || 'http://localhost:3001';
// Verify the proof the extension generated for a promotion. Uses the self-contained demo
// backend by default; set SHOPCONNECT_API_URL to delegate to a real ShopConnect API.
import { verifyProof } from '@/lib/shopconnect-demo';

const API = process.env.SHOPCONNECT_API_URL;

export async function POST(request) {
const body = await request.json();
if (!API) {
const result = verifyProof(body);
return Response.json(result, { status: result.approved ? 200 : 400 });
}
try {
const res = await fetch(`${API}/verify`, {
method: 'POST',
Expand Down
191 changes: 191 additions & 0 deletions packages/store/lib/shopconnect-demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Self-contained ShopConnect demo backend.
//
// Mirrors packages/api running in DEMO_MODE, but lives inside the Next.js app so the store
// deploys as a *single* application (e.g. on Vercel) with no separate Express service.
// The store's /api/shopconnect/* routes use this by default; set SHOPCONNECT_API_URL to
// proxy to a real ShopConnect API instead.
//
// Demo mode simulates the server-side verify/issue. The browser extension still generates
// *real* Polygon ID ZK proofs; only the cryptographic server checks are stubbed here.
import crypto from 'node:crypto';

const SHOPCONNECT_DID =
process.env.SHOPCONNECT_DID ||
'did:polygonid:polygon:amoy:2qQ68JkRcf3ymy9wtzKyY3Dnjs7YMSu3AXfnKz8n3';

const SCHEMA = {
type: 'ShopPurchase',
context:
process.env.SHOPPURCHASE_CONTEXT ||
'ipfs://QmSKwZp4NVA62ErcdvNjsyS8ffScqKEtBvAMpe6DLeMET2',
display: process.env.SHOPPURCHASE_DISPLAY || null,
};

// callbackUrl base for auth requests; empty by default (relative), overridable in prod.
const PUBLIC_URL = process.env.PUBLIC_URL || '';

// Deterministic UUID-shaped id from a seed — stable across requests, no `uuid` dependency.
function stableId(seed) {
const h = crypto.createHash('sha256').update(seed).digest('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
}
const reqId = (id) => stableId(`shopconnect:promo:${id}:req`);
const thId = (id) => stableId(`shopconnect:promo:${id}:thid`);
const randomUuid = () => crypto.randomUUID();

// The catalogue of promotions, each gated by a Polygon ID ShopPurchase query.
const CATALOGUE = [
{
id: 1,
title: 'Premium Coffee Beans — 20% off',
requirement: 'Spent > $800 on premium-brand coffee machines',
discount: 20,
discountType: 'percentage',
image:
'https://d1uutfeu2e3s3x.cloudfront.net/images/default-source/exclusives-and-gift-detail-(1000x1000)/packed-coffee/signature-nomad/bacha-fine-flavoured-grand-moka-matari-signature-nomad-packed-whole-coffee-beans.tmb-bc00000005.jpg',
query: {
allowedIssuers: ['*'],
context: SCHEMA.context,
type: SCHEMA.type,
credentialSubject: { 'item.category': { $eq: 'Coffee machine' } },
skipClaimRevocationCheck: true,
},
},
{
id: 2,
title: 'Nescafé Coffee Beans — 10% off',
requirement: 'Spent > $50 on any coffee machine',
discount: 10,
discountType: 'percentage',
image: 'https://m.media-amazon.com/images/I/81gW593lUFL._SL1500_.jpg',
query: {
allowedIssuers: ['*'],
context: SCHEMA.context,
type: SCHEMA.type,
credentialSubject: { 'item.category': { $eq: 'Coffee machine' } },
skipClaimRevocationCheck: true,
},
},
{
id: 3,
title: 'Recurring Customer — $20 off storewide',
requirement: 'Any previous storewide purchase',
discount: 20,
discountType: 'amount',
image: 'https://m.media-amazon.com/images/I/51IZXFH6bWL._AC_SL1036_.jpg',
query: {
allowedIssuers: ['*'],
context: SCHEMA.context,
type: SCHEMA.type,
credentialSubject: { 'item.name': { $eq: 'Storewide' } },
skipClaimRevocationCheck: true,
},
},
];

// Build the iden3comm authorization request the extension proves against.
export function buildAuthRequest(promotion) {
return {
id: reqId(promotion.id),
typ: 'application/iden3comm-plain-json',
type: 'https://iden3-communication.io/authorization/1.0/request',
thid: thId(promotion.id),
from: SHOPCONNECT_DID,
body: {
reason: `Promotion eligibility: ${promotion.title}`,
message: promotion.requirement,
callbackUrl: `${PUBLIC_URL}/api/shopconnect/verify?promotionId=${promotion.id}`,
scope: [
{ id: promotion.id, circuitId: 'credentialAtomicQuerySigV2', query: promotion.query },
],
},
};
}

// Public shape returned to the store/extension: display fields + the auth request.
export function getPromotions(/* site */) {
return CATALOGUE.map((p) => ({
id: p.id,
title: p.title,
requirement: p.requirement,
discount: p.discount,
discountType: p.discountType,
image: p.image,
authRequest: buildAuthRequest(p),
verificationQuery: { circuitId: 'credentialAtomicQuerySigV2', id: p.id, query: p.query },
}));
}

function getPromotion(id) {
return CATALOGUE.find((p) => p.id === Number(id));
}

// Demo verify: structural sanity check on the proof token (a JWZ is a non-trivial
// base64url payload). Real cryptographic verification lives in packages/api (real mode).
export function verifyProof({ promotionId, token }) {
const promotion = getPromotion(promotionId);
if (!promotion) return { approved: false, reason: 'unknown promotion' };
if (!token || typeof token !== 'string') {
return { approved: false, reason: 'missing proof token' };
}
const looksLikeJwz = token.length > 40 && /^[A-Za-z0-9_\-=.]+$/.test(token);
return {
approved: looksLikeJwz,
demo: true,
reason: looksLikeJwz ? 'accepted (demo mode)' : 'token does not look like a JWZ proof',
promotionId: promotion.id,
discount: promotion.discount,
discountType: promotion.discountType,
};
}

// Demo issue: mint a simulated ShopPurchase credential + iden3comm offer for the shopper's DID.
export function issueProofOfPurchase({ did, order }) {
if (!did) return { issued: false, reason: 'missing shopper DID' };
if (!order || !Array.isArray(order.items)) {
return { issued: false, reason: 'missing order items' };
}
const purchasedAt = Number.isFinite(Date.parse(order.timestamp))
? Math.floor(Date.parse(order.timestamp) / 1000)
: Math.floor(Date.now() / 1000);
const subjects = order.items.map((item) => ({
id: did,
item: { name: item.name, category: item.category, price: Number(item.price) },
order: { total: Number(order.total) },
purchasedAt,
}));
const displayMethod = SCHEMA.display
? { id: SCHEMA.display, type: 'Iden3BasicDisplayMethodV1' }
: undefined;
const credentials = subjects.map((subject) => ({
id: `urn:uuid:${randomUuid()}`,
'@context': ['https://www.w3.org/2018/credentials/v1', SCHEMA.context],
type: ['VerifiableCredential', SCHEMA.type],
issuer: SHOPCONNECT_DID,
issuanceDate: new Date().toISOString(),
credentialSubject: subject,
credentialStatus: { demo: true },
...(displayMethod && { displayMethod }),
}));
return {
issued: true,
demo: true,
credentials,
offer: buildOffer(did, credentials.map((c) => c.id)),
};
}

function buildOffer(toDid, credentialIds) {
return {
id: randomUuid(),
typ: 'application/iden3comm-plain-json',
type: 'https://iden3-communication.io/credentials/1.0/offer',
thid: randomUuid(),
from: SHOPCONNECT_DID,
to: toDid,
body: {
url: null, // demo: wallet consumes the inline credentials without an agent round-trip
credentials: credentialIds.map((id) => ({ id, description: SCHEMA.type })),
},
};
}
6 changes: 2 additions & 4 deletions packages/store/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
const nextConfig = {
reactStrictMode: true,
images: { unoptimized: true },
env: {
// Where the store's server routes reach the ShopConnect API.
SHOPCONNECT_API_URL: process.env.SHOPCONNECT_API_URL || 'http://localhost:3001',
},
// The store's /api/shopconnect/* routes use a self-contained demo backend by default.
// Set SHOPCONNECT_API_URL in the environment to proxy to a real ShopConnect API instead.
};
module.exports = nextConfig;