@porulle/plugin-channel-connector 0.9.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,95 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ export interface OAuthStatePayload {
4
+ provider: string;
5
+ orgId: string;
6
+ shopDomain: string;
7
+ exp: number;
8
+ jti: string;
9
+ }
10
+
11
+ export type OAuthStateResult =
12
+ | { ok: true; value: OAuthStatePayload }
13
+ | { ok: false; error: string };
14
+
15
+ const consumedJtis = new Map<string, number>();
16
+
17
+ function encodeText(value: string): string {
18
+ return Buffer.from(value, "utf8").toString("base64url");
19
+ }
20
+
21
+ function encodeBytes(value: Uint8Array): string {
22
+ return Buffer.from(value).toString("base64url");
23
+ }
24
+
25
+ function decode(value: string): string | undefined {
26
+ try {
27
+ return Buffer.from(value, "base64url").toString("utf8");
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ function signature(payload: string, secret: string): Buffer {
34
+ return createHmac("sha256", secret).update(payload).digest();
35
+ }
36
+
37
+ export function signState(payload: OAuthStatePayload, secret: string): string {
38
+ if (!secret) throw new Error("OAuth state secret is required.");
39
+ const encodedPayload = encodeText(JSON.stringify(payload));
40
+ return `${encodedPayload}.${encodeBytes(signature(encodedPayload, secret))}`;
41
+ }
42
+
43
+ export function verifyState(
44
+ state: string,
45
+ secret: string,
46
+ now = Math.floor(Date.now() / 1000),
47
+ consume = true,
48
+ ): OAuthStateResult {
49
+ if (!secret) return { ok: false, error: "OAuth state secret is required." };
50
+ const parts = state.split(".");
51
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return { ok: false, error: "Malformed OAuth state." };
52
+
53
+ const expected = signature(parts[0], secret);
54
+ let actual: Buffer;
55
+ try {
56
+ actual = Buffer.from(parts[1], "base64url");
57
+ } catch {
58
+ return { ok: false, error: "Malformed OAuth state signature." };
59
+ }
60
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
61
+ return { ok: false, error: "Invalid OAuth state signature." };
62
+ }
63
+
64
+ const decoded = decode(parts[0]);
65
+ if (!decoded) return { ok: false, error: "Malformed OAuth state payload." };
66
+ let payload: unknown;
67
+ try {
68
+ payload = JSON.parse(decoded);
69
+ } catch {
70
+ return { ok: false, error: "Malformed OAuth state payload." };
71
+ }
72
+ if (!payload || typeof payload !== "object") return { ok: false, error: "Malformed OAuth state payload." };
73
+ const candidate = payload as Partial<OAuthStatePayload>;
74
+ const exp = candidate.exp;
75
+ if (
76
+ typeof candidate.provider !== "string" ||
77
+ typeof candidate.orgId !== "string" ||
78
+ typeof candidate.shopDomain !== "string" ||
79
+ typeof candidate.jti !== "string" ||
80
+ typeof exp !== "number" ||
81
+ !Number.isInteger(exp)
82
+ ) return { ok: false, error: "Malformed OAuth state payload." };
83
+ if (exp <= now) return { ok: false, error: "OAuth state has expired." };
84
+ for (const [jti, expiresAt] of consumedJtis) {
85
+ if (expiresAt <= now) consumedJtis.delete(jti);
86
+ }
87
+ if (consume && consumedJtis.has(candidate.jti)) return { ok: false, error: "OAuth state has already been used." };
88
+ if (consume) consumedJtis.set(candidate.jti, exp);
89
+
90
+ return { ok: true, value: candidate as OAuthStatePayload };
91
+ }
92
+
93
+ export function oauthStateEventId(jti: string): string {
94
+ return `oauth-state:${jti}`;
95
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,158 @@
1
+ import {
2
+ index,
3
+ integer,
4
+ jsonb,
5
+ pgTable,
6
+ text,
7
+ timestamp,
8
+ uniqueIndex,
9
+ uuid,
10
+ } from "@porulle/core/drizzle";
11
+
12
+ export const connectedStores = pgTable(
13
+ "connected_stores",
14
+ {
15
+ id: uuid("id").defaultRandom().primaryKey(),
16
+ organizationId: text("organization_id").notNull(),
17
+ provider: text("provider").notNull(),
18
+ credentials: jsonb("credentials").$type<Record<string, unknown>>().notNull(),
19
+ storeDomain: text("store_domain").notNull(),
20
+ status: text("status", { enum: ["connected", "disconnected", "error"] })
21
+ .notNull()
22
+ .default("connected"),
23
+ catalogCursor: text("catalog_cursor"),
24
+ inventoryCursor: text("inventory_cursor"),
25
+ lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
26
+ lastReconcileAt: timestamp("last_reconcile_at", { withTimezone: true }),
27
+ lastReconcileReport: jsonb("last_reconcile_report").$type<Record<string, unknown>>(),
28
+ webhookSecret: text("webhook_secret"),
29
+ breakerState: jsonb("breaker_state").$type<Record<string, unknown>>().notNull().default({}),
30
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
31
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
32
+ },
33
+ (table) => ({
34
+ orgIdx: index("idx_connected_stores_org").on(table.organizationId),
35
+ orgProviderIdx: index("idx_connected_stores_org_provider").on(table.organizationId, table.provider),
36
+ }),
37
+ );
38
+
39
+ export const channelEntityMap = pgTable(
40
+ "channel_entity_map",
41
+ {
42
+ id: uuid("id").defaultRandom().primaryKey(),
43
+ organizationId: text("organization_id").notNull(),
44
+ storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
45
+ kind: text("kind", { enum: ["entity", "variant"] }).notNull(),
46
+ externalId: text("external_id").notNull(),
47
+ entityId: uuid("entity_id").notNull(),
48
+ variantId: uuid("variant_id"),
49
+ lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).defaultNow().notNull(),
50
+ syncHash: text("sync_hash").notNull(),
51
+ },
52
+ (table) => ({
53
+ orgIdx: index("idx_channel_entity_map_org").on(table.organizationId),
54
+ storeIdx: index("idx_channel_entity_map_store").on(table.storeId),
55
+ externalUnique: uniqueIndex("channel_entity_map_store_kind_external_unique").on(
56
+ table.storeId,
57
+ table.kind,
58
+ table.externalId,
59
+ ),
60
+ }),
61
+ );
62
+
63
+ export const channelOrderExports = pgTable(
64
+ "channel_order_exports",
65
+ {
66
+ id: uuid("id").defaultRandom().primaryKey(),
67
+ organizationId: text("organization_id").notNull(),
68
+ storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
69
+ orderId: uuid("order_id").notNull(),
70
+ customerData: jsonb("customer_data").$type<{
71
+ name: string;
72
+ email: string;
73
+ shippingAddress: Record<string, unknown>;
74
+ }>(),
75
+ state: text("state", { enum: ["pending", "exported", "confirmed", "failed", "abandoned"] })
76
+ .notNull()
77
+ .default("pending"),
78
+ failureKind: text("failure_kind", { enum: ["definitive", "transient"] }),
79
+ remoteOrderId: text("remote_order_id"),
80
+ remoteUrl: text("remote_url"),
81
+ attempts: integer("attempts").notNull().default(0),
82
+ lastError: text("last_error"),
83
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
84
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
85
+ },
86
+ (table) => ({
87
+ orgIdx: index("idx_channel_order_exports_org").on(table.organizationId),
88
+ storeIdx: index("idx_channel_order_exports_store").on(table.storeId),
89
+ stateIdx: index("idx_channel_order_exports_state").on(table.organizationId, table.state),
90
+ orderIdx: index("idx_channel_order_exports_order").on(table.organizationId, table.orderId),
91
+ }),
92
+ );
93
+
94
+ export const channelExportEvents = pgTable(
95
+ "channel_export_events",
96
+ {
97
+ id: uuid("id").defaultRandom().primaryKey(),
98
+ organizationId: text("organization_id").notNull(),
99
+ exportId: uuid("export_id")
100
+ .references(() => channelOrderExports.id, { onDelete: "cascade" })
101
+ .notNull(),
102
+ fromState: text("from_state").notNull(),
103
+ toState: text("to_state").notNull(),
104
+ reason: text("reason"),
105
+ changedBy: text("changed_by").notNull(),
106
+ changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
107
+ },
108
+ (table) => ({
109
+ orgIdx: index("idx_channel_export_events_org").on(table.organizationId),
110
+ exportIdx: index("idx_channel_export_events_export").on(table.exportId),
111
+ }),
112
+ );
113
+
114
+ export const channelRefundRequests = pgTable(
115
+ "channel_refund_requests",
116
+ {
117
+ id: uuid("id").defaultRandom().primaryKey(),
118
+ organizationId: text("organization_id").notNull(),
119
+ storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
120
+ orderId: uuid("order_id").notNull(),
121
+ remoteRefundId: text("remote_refund_id").notNull(),
122
+ amount: integer("amount").notNull(),
123
+ state: text("state", { enum: ["requested", "approved", "rejected", "executed"] }).notNull().default("requested"),
124
+ approvedBy: text("approved_by"),
125
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
126
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
127
+ },
128
+ (table) => ({
129
+ orgIdx: index("idx_channel_refund_requests_org").on(table.organizationId),
130
+ pendingIdx: index("idx_channel_refund_requests_pending").on(table.organizationId, table.state),
131
+ remoteUnique: uniqueIndex("channel_refund_requests_store_remote_unique").on(table.storeId, table.remoteRefundId),
132
+ }),
133
+ );
134
+
135
+ export const channelRefundEvents = pgTable(
136
+ "channel_refund_events",
137
+ {
138
+ id: uuid("id").defaultRandom().primaryKey(),
139
+ organizationId: text("organization_id").notNull(),
140
+ requestId: uuid("request_id").references(() => channelRefundRequests.id, { onDelete: "cascade" }).notNull(),
141
+ fromState: text("from_state"),
142
+ toState: text("to_state").notNull(),
143
+ reason: text("reason"),
144
+ changedBy: text("changed_by").notNull(),
145
+ changedAt: timestamp("changed_at", { withTimezone: true }).defaultNow().notNull(),
146
+ },
147
+ (table) => ({
148
+ orgIdx: index("idx_channel_refund_events_org").on(table.organizationId),
149
+ requestIdx: index("idx_channel_refund_events_request").on(table.requestId),
150
+ }),
151
+ );
152
+
153
+ export type ConnectedStore = typeof connectedStores.$inferSelect;
154
+ export type ChannelEntityMapEntry = typeof channelEntityMap.$inferSelect;
155
+ export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
156
+ export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
157
+ export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
158
+ export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;