diff --git a/.env.example b/.env.example index b4d91c8..d7376e3 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,5 @@ SHOPIFY_API_KEY=your_client_id SHOPIFY_API_SECRET=your_client_secret SHOPIFY_APP_HANDLE=offerlyst POSTGRES_PASSWORD=change-me-to-a-long-random-string +# Encrypts order data at rest. 64 hex chars: openssl rand -hex 32. Back it up; never change it. +DATA_ENCRYPTION_KEY= diff --git a/app/lib/crypto.server.ts b/app/lib/crypto.server.ts new file mode 100644 index 0000000..7d593dd --- /dev/null +++ b/app/lib/crypto.server.ts @@ -0,0 +1,48 @@ +// Encryption at rest for order data (Shopify protected customer data, Level 1). +// - hashId(): keyed HMAC-SHA256, deterministic, so order ids can still be counted and de-duplicated +// without storing them readably. +// - encrypt()/decrypt(): AES-256-GCM for values we need to show again (order number, order id for links). +// Key: DATA_ENCRYPTION_KEY = 64 hex characters (openssl rand -hex 32). Keep it out of Git; losing it makes +// existing analytics rows unreadable (they still count). +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto"; + +let keys: { enc: Buffer; mac: Buffer } | null = null; + +function getKeys() { + if (keys) return keys; + const hex = process.env.DATA_ENCRYPTION_KEY || ""; + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error("DATA_ENCRYPTION_KEY must be 64 hex characters (openssl rand -hex 32)"); + } + const master = Buffer.from(hex, "hex"); + keys = { + enc: createHash("sha256").update(Buffer.concat([master, Buffer.from("offerlyst:enc:v1")])).digest(), + mac: createHash("sha256").update(Buffer.concat([master, Buffer.from("offerlyst:mac:v1")])).digest(), + }; + return keys; +} + +export function hashId(value: string) { + return createHmac("sha256", getKeys().mac).update(value).digest("hex"); +} + +export function encrypt(value: string | null | undefined): string | null { + if (value == null || value === "") return null; + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", getKeys().enc, iv); + const data = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return `v1:${Buffer.concat([iv, cipher.getAuthTag(), data]).toString("base64")}`; +} + +export function decrypt(value: string | null | undefined): string | null { + if (!value) return null; + if (!value.startsWith("v1:")) return value; // not encrypted (should not happen) + try { + const raw = Buffer.from(value.slice(3), "base64"); + const decipher = createDecipheriv("aes-256-gcm", getKeys().enc, raw.subarray(0, 12)); + decipher.setAuthTag(raw.subarray(12, 28)); + return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8"); + } catch { + return null; + } +} diff --git a/app/lib/orders.server.ts b/app/lib/orders.server.ts index 8940754..8c67991 100644 --- a/app/lib/orders.server.ts +++ b/app/lib/orders.server.ts @@ -2,6 +2,7 @@ // one line carries two offers; we attribute by name. If Shopify reports only the discount title // (DISCOUNT_TITLE, "Offerlyst bundles"), the usage is kept under that title. Verify the exact title on a dev store (README). import db from "../db.server"; +import { encrypt, hashId } from "./crypto.server"; export interface OrderPayload { id: number | string; @@ -22,7 +23,8 @@ export async function recordOrder( offers: { id: string; name: string }[], discountTitle: string, ) { - const orderId = String(order.id); + const rawOrderId = String(order.id); + const orderId = hashId(rawOrderId); // stored hashed: protected customer data, encrypted at rest const apps = order.discount_applications || []; const byName = new Map(offers.map((o) => [o.name, o.id])); const ours = new Map(); // application index -> offer names @@ -62,7 +64,7 @@ export async function recordOrder( await db.orderUsage.upsert({ where: { shopId_orderId_offerName: { shopId, orderId, offerName: name } }, create: { - shopId, orderId, orderName: order.name || null, offerId: byName.get(name) || null, offerName: name, + shopId, orderId, orderRef: encrypt(rawOrderId), orderName: encrypt(order.name || null), offerId: byName.get(name) || null, offerName: name, discount: Math.round(v.discount * 100) / 100, bundleRevenue: Math.round(v.revenue * 100) / 100, orderTotal: total, currency, }, update: {}, diff --git a/app/routes/app.analytics.tsx b/app/routes/app.analytics.tsx index 7b68325..44f8526 100644 --- a/app/routes/app.analytics.tsx +++ b/app/routes/app.analytics.tsx @@ -7,6 +7,7 @@ import { ensureShop } from "../lib/shop.server"; import { planOf } from "../lib/plans"; import { moneyFormatter } from "../lib/offer-text"; import { formatShopDate } from "../lib/tz"; +import { decrypt } from "../lib/crypto.server"; const RANGES: Record = { "7": 7, "30": 30, "90": 90, "365": 365 }; @@ -49,7 +50,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }, offers: offers.map((o) => ({ name: o.name, orders: o.orders.size, revenue: money(o.revenue), discount: money(o.discount) })), recent: recent.map((u) => ({ - id: u.id, orderId: u.orderId, orderName: u.orderName || u.orderId, offer: u.offerName, + id: u.id, orderId: decrypt(u.orderRef) || "", orderName: decrypt(u.orderName) || "Order", offer: u.offerName, discount: money(Number(u.discount)), at: formatShopDate(u.createdAt, shop.timezone), })), shopDomain: shop.domain, @@ -135,7 +136,7 @@ export default function Analytics() { {d.recent.map((r) => ( - {r.orderName} + {r.orderId ? {r.orderName} : r.orderName} {r.offer} {r.discount} {r.at} diff --git a/app/routes/privacy.tsx b/app/routes/privacy.tsx index 59e014c..508c7fa 100644 --- a/app/routes/privacy.tsx +++ b/app/routes/privacy.tsx @@ -23,7 +23,7 @@ export default function Privacy() {

Only to provide the app: apply bundle discounts, show offers on your storefront, and report bundle sales to you. We don't sell data or use it for advertising.

Where it's stored

-

On our servers and in your Shopify store (app metafields and one automatic discount). Data is encrypted in transit (HTTPS).

+

On our servers and in your Shopify store (app metafields and one automatic discount). Data is encrypted in transit (HTTPS), and order identifiers are encrypted at rest (AES-256-GCM and keyed hashing). Our data processing terms are in the terms of service.

Retention and deletion

When you uninstall the app, Shopify removes the app's discount and metafields. We delete all data about your store 48 hours after uninstall, when Shopify sends the shop/redact request. Customer data requests and redaction requests are answered as required; we hold no customer personal data.

diff --git a/app/routes/terms.tsx b/app/routes/terms.tsx new file mode 100644 index 0000000..febdef6 --- /dev/null +++ b/app/routes/terms.tsx @@ -0,0 +1,43 @@ +// Public terms of service with data processing terms (the merchant agreement Shopify's protected customer +// data requirements ask for): https:///terms +import type { MetaFunction } from "react-router"; + +export const meta: MetaFunction = () => [{ title: "Terms of service · Offerlyst" }]; + +const CONTACT = "info@metatroncubesolutions.com"; + +export default function Terms() { + return ( +
+

Terms of service

+

Last updated: October 14, 2026. These terms apply when a merchant installs Offerlyst (provided by Metatroncube Software Solutions, “we”) on a Shopify store. By installing the app you agree to them. Our privacy policy forms part of these terms.

+ +

The service

+

Offerlyst lets you create bundle offers that Shopify applies as automatic discounts, and shows those offers on your storefront. Plans and prices are shown and billed through Shopify. You can uninstall at any time.

+ +

Data processing terms

+

For personal data about your customers, you are the controller and we act as your processor. We:

+
    +
  • process order data only to provide the app's features (applying bundle discounts and bundle analytics) and on your instructions given through the app;
  • +
  • collect only order ID, order number, order total and discount amounts; we do not collect customer names, emails, phone numbers or addresses;
  • +
  • do not sell or share personal data, and do not use it for advertising, profiling, automated decisions or training AI models;
  • +
  • encrypt data in transit (HTTPS) and encrypt order identifiers at rest (AES-256-GCM, keyed hashing);
  • +
  • limit access to our systems to authorised staff bound by confidentiality;
  • +
  • use our hosting provider as a sub-processor for servers and storage, and will tell you before adding others;
  • +
  • notify you without undue delay, and within 72 hours, after becoming aware of a breach affecting your data;
  • +
  • help you respond to customer requests (access, deletion) received through Shopify's privacy webhooks;
  • +
  • delete all data about your store 48 hours after you uninstall the app.
  • +
+

You are responsible for telling your customers how their data is used in your own store's privacy notice, and for having a lawful basis for the processing.

+ +

Acceptable use

+

Don't use the app for unlawful, misleading or deceptive offers. You're responsible for the offers you create and their compliance with consumer and pricing laws where you sell.

+ +

Liability

+

The app is provided as is. To the extent allowed by law, our total liability is limited to the fees you paid for the app in the 12 months before the claim.

+ +

Changes and contact

+

We may update these terms and will notify you in the app or by email for material changes. Questions: {CONTACT}

+
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml index e59c43e..0763c73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: SHOPIFY_API_SECRET: ${SHOPIFY_API_SECRET:?set SHOPIFY_API_SECRET in .env} SHOPIFY_APP_URL: https://${APP_DOMAIN:?set APP_DOMAIN in .env} SHOPIFY_APP_HANDLE: ${SHOPIFY_APP_HANDLE:-offerlyst} + DATA_ENCRYPTION_KEY: ${DATA_ENCRYPTION_KEY:?set DATA_ENCRYPTION_KEY in .env (openssl rand -hex 32)} SCOPES: read_products,write_discounts,read_discounts,read_orders,write_files,read_themes,write_app_proxy expose: - "3000" diff --git a/engine/tests/crypto.test.js b/engine/tests/crypto.test.js new file mode 100644 index 0000000..00f4d3f --- /dev/null +++ b/engine/tests/crypto.test.js @@ -0,0 +1,17 @@ +// Encryption at rest for order data: round trip, randomised ciphertext, stable keyed hash, key required. +import { test, expect } from 'vitest'; + +test('order data is encrypted and hashed with the configured key', async () => { + process.env.DATA_ENCRYPTION_KEY = 'a'.repeat(64); + const { encrypt, decrypt, hashId } = await import('../../app/lib/crypto.server.ts'); + const c1 = encrypt('gid://shopify/Order/12345'); + const c2 = encrypt('gid://shopify/Order/12345'); + expect(c1).toMatch(/^v1:/); + expect(c1).not.toContain('12345'); + expect(c1).not.toEqual(c2); // random IV + expect(decrypt(c1)).toBe('gid://shopify/Order/12345'); + expect(decrypt('v1:' + Buffer.from('tampered-data-xxxxxxxxxxxxxxxxxxxx').toString('base64'))).toBeNull(); + expect(hashId('12345')).toEqual(hashId('12345')); + expect(hashId('12345')).not.toContain('12345'); + expect(encrypt(null)).toBeNull(); +}); diff --git a/prisma/migrations/20261014142857_encrypt_order_data/migration.sql b/prisma/migrations/20261014142857_encrypt_order_data/migration.sql new file mode 100644 index 0000000..6e4934e --- /dev/null +++ b/prisma/migrations/20261014142857_encrypt_order_data/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OrderUsage" ADD COLUMN "orderRef" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d1b2511..f53efeb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,7 +136,11 @@ model OrderUsage { id String @id @default(cuid()) shopId String shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + /// keyed hash of the Shopify order id (app/lib/crypto.server.ts) orderId String + /// AES-GCM encrypted order id, for the admin link + orderRef String? + /// AES-GCM encrypted order number (#1001) orderName String? offerId String? offer Offer? @relation(fields: [offerId], references: [id], onDelete: SetNull) @@ -155,6 +159,7 @@ model OrderUsage { model OrderStat { id String @id @default(cuid()) shopId String + /// keyed hash of the Shopify order id orderId String total Decimal @db.Decimal(12, 2) hasBundle Boolean diff --git a/shopify.app.toml b/shopify.app.toml index 0d0fb04..f200c01 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -10,7 +10,6 @@ embedded = true [build] # keep production URLs from being overwritten by `shopify app dev` automatically_update_urls_on_dev = false -include_config_on_deploy = true [access_scopes] # Brief §15 + write_app_proxy (required by Shopify for the app proxy). read_orders: analytics. write_files: offer images.