Encrypt order data at rest; add terms of service with data processing terms
Protected customer data (Level 1): order ids are stored as keyed HMACs, order numbers and ids for admin links as AES-256-GCM ciphertext (DATA_ENCRYPTION_KEY). New public /terms page with processor terms; privacy policy links to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cd4859d107
commit
38c03cd1ae
@ -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=
|
||||
|
||||
48
app/lib/crypto.server.ts
Normal file
48
app/lib/crypto.server.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<number, string[]>(); // 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: {},
|
||||
|
||||
@ -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<string, number> = { "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() {
|
||||
<s-table-body>
|
||||
{d.recent.map((r) => (
|
||||
<s-table-row key={r.id}>
|
||||
<s-table-cell><s-link href={`shopify://admin/orders/${r.orderId}`} target="_top">{r.orderName}</s-link></s-table-cell>
|
||||
<s-table-cell>{r.orderId ? <s-link href={`shopify://admin/orders/${r.orderId}`} target="_top">{r.orderName}</s-link> : r.orderName}</s-table-cell>
|
||||
<s-table-cell>{r.offer}</s-table-cell>
|
||||
<s-table-cell>{r.discount}</s-table-cell>
|
||||
<s-table-cell>{r.at}</s-table-cell>
|
||||
|
||||
@ -23,7 +23,7 @@ export default function Privacy() {
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Where it's stored</h2>
|
||||
<p>On our servers and in your Shopify store (app metafields and one automatic discount). Data is encrypted in transit (HTTPS).</p>
|
||||
<p>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 <a href="/terms">terms of service</a>.</p>
|
||||
|
||||
<h2>Retention and deletion</h2>
|
||||
<p>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.</p>
|
||||
|
||||
43
app/routes/terms.tsx
Normal file
43
app/routes/terms.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
// Public terms of service with data processing terms (the merchant agreement Shopify's protected customer
|
||||
// data requirements ask for): https://<app domain>/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 (
|
||||
<main style={{ maxWidth: 720, margin: "48px auto", padding: "0 20px", font: "16px/1.6 system-ui, sans-serif", color: "#1a1a1a" }}>
|
||||
<h1>Terms of service</h1>
|
||||
<p>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 <a href="/privacy">privacy policy</a> forms part of these terms.</p>
|
||||
|
||||
<h2>The service</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Data processing terms</h2>
|
||||
<p>For personal data about your customers, you are the controller and we act as your processor. We:</p>
|
||||
<ul>
|
||||
<li>process order data only to provide the app's features (applying bundle discounts and bundle analytics) and on your instructions given through the app;</li>
|
||||
<li>collect only order ID, order number, order total and discount amounts; we do not collect customer names, emails, phone numbers or addresses;</li>
|
||||
<li>do not sell or share personal data, and do not use it for advertising, profiling, automated decisions or training AI models;</li>
|
||||
<li>encrypt data in transit (HTTPS) and encrypt order identifiers at rest (AES-256-GCM, keyed hashing);</li>
|
||||
<li>limit access to our systems to authorised staff bound by confidentiality;</li>
|
||||
<li>use our hosting provider as a sub-processor for servers and storage, and will tell you before adding others;</li>
|
||||
<li>notify you without undue delay, and within 72 hours, after becoming aware of a breach affecting your data;</li>
|
||||
<li>help you respond to customer requests (access, deletion) received through Shopify's privacy webhooks;</li>
|
||||
<li>delete all data about your store 48 hours after you uninstall the app.</li>
|
||||
</ul>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Acceptable use</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Liability</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Changes and contact</h2>
|
||||
<p>We may update these terms and will notify you in the app or by email for material changes. Questions: <a href={`mailto:${CONTACT}`}>{CONTACT}</a></p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
17
engine/tests/crypto.test.js
Normal file
17
engine/tests/crypto.test.js
Normal file
@ -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();
|
||||
});
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrderUsage" ADD COLUMN "orderRef" TEXT;
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user