@objectsws/s-portal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const HEARTBEAT_URL = process.env.PORTAL_HEARTBEAT_URL ?? "";
4
+ const WEBHOOK_URL = process.env.PORTAL_WEBHOOK_URL ?? "";
5
+ const CLIENT_ID = process.env.PORTAL_CLIENT_ID ?? "";
6
+ const CLIENT_SECRET = process.env.PORTAL_CLIENT_SECRET ?? "";
7
+
8
+ function portalHeaders(body: string) {
9
+ const timestamp = Date.now().toString();
10
+ const signature = crypto
11
+ .createHmac("sha256", CLIENT_SECRET)
12
+ .update(`${timestamp}.${body}`)
13
+ .digest("hex");
14
+ return {
15
+ "content-type": "application/json",
16
+ "x-portal-client-id": CLIENT_ID,
17
+ "x-portal-timestamp": timestamp,
18
+ "x-portal-signature": signature,
19
+ };
20
+ }
21
+
22
+ export async function sendPortalHeartbeat(input: {
23
+ status: "up" | "down" | "degraded" | "unknown";
24
+ latencyMs?: number;
25
+ error?: string | null;
26
+ meta?: Record<string, unknown>;
27
+ }) {
28
+ if (!HEARTBEAT_URL || !CLIENT_ID || !CLIENT_SECRET) return;
29
+ const body = JSON.stringify({
30
+ status: input.status,
31
+ latencyMs: input.latencyMs,
32
+ error: input.error ?? null,
33
+ observedAt: new Date().toISOString(),
34
+ meta: input.meta ?? {},
35
+ });
36
+ await fetch(HEARTBEAT_URL, { method: "POST", headers: portalHeaders(body), body });
37
+ }
38
+
39
+ export async function sendPortalWebhook(input: {
40
+ topic: string;
41
+ shop: string;
42
+ shopName?: string;
43
+ payload?: Record<string, unknown>;
44
+ }) {
45
+ if (!WEBHOOK_URL || !CLIENT_ID || !CLIENT_SECRET) return;
46
+ const body = JSON.stringify({
47
+ topic: input.topic,
48
+ shop: input.shop,
49
+ shopName: input.shopName ?? null,
50
+ payload: input.payload ?? {},
51
+ });
52
+ await fetch(WEBHOOK_URL, { method: "POST", headers: portalHeaders(body), body });
53
+ }
@@ -0,0 +1,28 @@
1
+ import { sendPortalHeartbeat } from "./client.server";
2
+
3
+ let started = false;
4
+
5
+ /**
6
+ * Call once from app/entry.server.tsx (or your server entry) on boot.
7
+ */
8
+ export function startHeartbeatLoop() {
9
+ if (started) return;
10
+ started = true;
11
+ const intervalMs = Number(process.env.HEARTBEAT_INTERVAL_MS || 60_000);
12
+
13
+ const tick = async () => {
14
+ const t0 = Date.now();
15
+ try {
16
+ await sendPortalHeartbeat({ status: "up", latencyMs: Date.now() - t0 });
17
+ } catch (e) {
18
+ await sendPortalHeartbeat({
19
+ status: "down",
20
+ latencyMs: Date.now() - t0,
21
+ error: e instanceof Error ? e.message : "heartbeat failed",
22
+ });
23
+ }
24
+ };
25
+
26
+ void tick();
27
+ setInterval(() => void tick(), intervalMs);
28
+ }
@@ -0,0 +1,79 @@
1
+ import { sendPortalWebhook } from "./client.server";
2
+
3
+ // Call these from your Shopify webhook handlers so merchants appear in the portal.
4
+
5
+ export async function handleAppInstalled(shop: string, shopName: string, plan?: string) {
6
+ await sendPortalWebhook({ topic: "APP_INSTALLED", shop, shopName, payload: { plan } });
7
+ }
8
+
9
+ export async function handleRelationshipInstalled(shop: string, shopName: string, plan?: string) {
10
+ await sendPortalWebhook({ topic: "RELATIONSHIP_INSTALLED", shop, shopName, payload: { plan } });
11
+ }
12
+
13
+ export async function handleAppUninstalled(
14
+ shop: string,
15
+ shopName: string,
16
+ phone?: string | null
17
+ ) {
18
+ await sendPortalWebhook({ topic: "APP_UNINSTALLED", shop, shopName, payload: { phone } });
19
+ }
20
+
21
+ export async function handleRelationshipUninstalled(
22
+ shop: string,
23
+ shopName: string,
24
+ phone?: string | null
25
+ ) {
26
+ await sendPortalWebhook({
27
+ topic: "RELATIONSHIP_UNINSTALLED",
28
+ shop,
29
+ shopName,
30
+ payload: { phone },
31
+ });
32
+ }
33
+
34
+ type SubStatus = "ACTIVE" | "CANCELLED" | "PENDING" | "DECLINED" | "EXPIRED" | "FROZEN";
35
+
36
+ interface AppSubscription {
37
+ admin_graphql_api_id: string;
38
+ status: SubStatus;
39
+ name: string;
40
+ plan_handle: string;
41
+ price: string;
42
+ interval: string;
43
+ capped_amount: string;
44
+ }
45
+
46
+ export async function handleAppSubscriptionUpdate(
47
+ shop: string,
48
+ shopName: string,
49
+ payload: { app_subscription: AppSubscription }
50
+ ) {
51
+ const sub = payload.app_subscription;
52
+ await sendPortalWebhook({
53
+ topic: "APP_SUBSCRIPTIONS_UPDATE",
54
+ shop,
55
+ shopName,
56
+ payload: {
57
+ subscriptionId: sub.admin_graphql_api_id,
58
+ status: sub.status,
59
+ planHandle: sub.plan_handle,
60
+ planName: sub.name,
61
+ price: sub.price,
62
+ interval: sub.interval,
63
+ cappedAmount: sub.capped_amount,
64
+ },
65
+ });
66
+ }
67
+
68
+ export async function handleApproachingCap(
69
+ shop: string,
70
+ shopName: string,
71
+ payload: Record<string, unknown>
72
+ ) {
73
+ await sendPortalWebhook({
74
+ topic: "APP_SUBSCRIPTIONS_APPROACHING_CAPPED_AMOUNT",
75
+ shop,
76
+ shopName,
77
+ payload,
78
+ });
79
+ }
@@ -0,0 +1,90 @@
1
+ import crypto from "node:crypto";
2
+ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
3
+ import { json } from "@remix-run/node";
4
+
5
+ const CLIENT_ID = process.env.PORTAL_CLIENT_ID ?? "";
6
+ const CLIENT_SECRET = process.env.PORTAL_CLIENT_SECRET ?? "";
7
+ /** Portal requires remaining TTL ≤ 2 minutes */
8
+ const TOKEN_TTL_MS = 90_000;
9
+
10
+ const activeTokens = new Map<string, { expiresAt: number }>();
11
+
12
+ function verifyPortalSyncRequest(headers: Headers, body: string): boolean {
13
+ const timestamp = headers.get("x-portal-timestamp") ?? "";
14
+ const nonce = headers.get("x-portal-nonce") ?? "";
15
+ const signature = headers.get("x-portal-signature") ?? "";
16
+ const payload = JSON.parse(body || "{}") as { clientId?: string };
17
+
18
+ if (!timestamp || !nonce || !signature || payload.clientId !== CLIENT_ID) return false;
19
+ if (Math.abs(Date.now() - Number(timestamp)) > 120_000) return false;
20
+
21
+ const expected = crypto
22
+ .createHmac("sha256", CLIENT_SECRET)
23
+ .update(`${timestamp}.${nonce}.${CLIENT_ID}`)
24
+ .digest("hex");
25
+
26
+ const a = Buffer.from(expected, "hex");
27
+ const b = Buffer.from(signature.toLowerCase(), "hex");
28
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
29
+ }
30
+
31
+ // POST /api/portal-sync/token
32
+ export async function action({ request }: ActionFunctionArgs) {
33
+ if (request.method !== "POST") return json({ error: "Method not allowed" }, { status: 405 });
34
+ const rawBody = await request.text();
35
+ if (!verifyPortalSyncRequest(request.headers, rawBody)) {
36
+ return json({ error: "Unauthorized" }, { status: 401 });
37
+ }
38
+
39
+ const token = crypto.randomBytes(32).toString("hex");
40
+ const expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString();
41
+ activeTokens.set(token, { expiresAt: Date.now() + TOKEN_TTL_MS });
42
+ return json({ accessToken: token, expiresAt });
43
+ }
44
+
45
+ // GET /api/portal-sync/merchants?cursor=<opaque>
46
+ // MerchantRow MUST include: id (string), shop (string)
47
+ export async function loader({ request }: LoaderFunctionArgs) {
48
+ const auth = request.headers.get("authorization") ?? "";
49
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
50
+ const entry = activeTokens.get(token);
51
+ if (!entry || Date.now() > entry.expiresAt) {
52
+ return json({ error: "Unauthorized" }, { status: 401 });
53
+ }
54
+
55
+ const PAGE = 100;
56
+ const cursor = new URL(request.url).searchParams.get("cursor");
57
+ const offset = cursor ? Number(Buffer.from(cursor, "base64url").toString()) : 0;
58
+
59
+ // TODO: replace with your real ORM query
60
+ // const rows = await db.select().from(sessions).limit(PAGE).offset(offset);
61
+ const rows: Array<{
62
+ id: string;
63
+ shop: string;
64
+ name: string | null;
65
+ email: string | null;
66
+ appStatus: string;
67
+ appPlan: string;
68
+ appName: string;
69
+ createdAt?: string;
70
+ }> = [];
71
+
72
+ const nextCursor =
73
+ rows.length === PAGE
74
+ ? Buffer.from(String(offset + rows.length)).toString("base64url")
75
+ : null;
76
+
77
+ return json({
78
+ items: rows.map((r) => ({
79
+ id: r.id,
80
+ shop: r.shop,
81
+ name: r.name,
82
+ email: r.email,
83
+ appStatus: r.appStatus,
84
+ appPlan: r.appPlan,
85
+ appName: r.appName,
86
+ createdAt: r.createdAt,
87
+ })),
88
+ nextCursor,
89
+ });
90
+ }
@@ -0,0 +1,30 @@
1
+ import type { LoaderFunctionArgs } from "@remix-run/node";
2
+ import { json } from "@remix-run/node";
3
+
4
+ async function checkReadiness() {
5
+ // Add any DB / Redis checks you need here
6
+ return { ok: true, error: null as string | null };
7
+ }
8
+
9
+ export async function loader(_: LoaderFunctionArgs) {
10
+ const t0 = Date.now();
11
+ const { ok, error } = await checkReadiness();
12
+ if (!ok) {
13
+ return json(
14
+ {
15
+ ok: false,
16
+ status: "down",
17
+ latencyMs: Date.now() - t0,
18
+ error,
19
+ ts: new Date().toISOString(),
20
+ },
21
+ { status: 503 }
22
+ );
23
+ }
24
+ return json({
25
+ ok: true,
26
+ status: "up",
27
+ latencyMs: Date.now() - t0,
28
+ ts: new Date().toISOString(),
29
+ });
30
+ }