@basezero/sdk 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.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # @basezero/sdk
2
+
3
+ Small, dependency-free **server-side** client for BaseZero app events. Requires an existing Shopify-mapped BaseZero Project and its API key. No install-state tracking in the app, no Shopify token sharing, no email automation.
4
+
5
+ ```ts
6
+ import { BaseZero } from '@basezero/sdk';
7
+
8
+ const basezero = new BaseZero({
9
+ baseUrl: 'https://YOUR_BASEZERO_HOST',
10
+ apiKey: process.env.BASEZERO_API_KEY!,
11
+ });
12
+
13
+ // After authenticating a live shop request:
14
+ await basezero.identify({
15
+ platformId: String(shop.id),
16
+ myshopifyDomain: shop.myshopifyDomain,
17
+ name: shop.name,
18
+ email: shop.email,
19
+ });
20
+
21
+ // Only after authenticating an app/uninstalled webhook:
22
+ await basezero.uninstall(
23
+ { platformId: String(payload.id) },
24
+ {
25
+ eventId: `shopify-uninstall:${shopifyEventId}`,
26
+ occurredAt: new Date(shopifyTriggeredAt).toISOString(),
27
+ },
28
+ );
29
+ ```
30
+
31
+ Use Shopify's original `X-Shopify-Event-Id` (fallback `X-Shopify-Webhook-Id`) and `X-Shopify-Triggered-At` for uninstalls. Missing metadata must be recovered, not replaced with the retry time.
32
+
33
+ BaseZero derives an inferred install from the first identify, suppresses repeated identify lifecycle events, and derives an inferred reinstall after an uninstall. First identify is not proof of the original Shopify install date. Missed uninstall cycles cannot be inferred from identify alone. Don't identify shops by scanning stale stored sessions.
34
+
35
+ ## Delivery
36
+
37
+ Every call is awaited. Defaults: 3 attempts, 5-second request timeout, exponential retry delay. Network failures, 408, 429 and 5xx retry with identical event ID, time and content. Configure `attempts` (1–5), `timeoutMs` (1–30000), or a custom `fetch`. HTTPS is mandatory except localhost. Redirects and browser usage are rejected.
38
+
39
+ The retry budget may exceed Shopify's webhook acknowledgement deadline: prefer a durable webhook queue. Never acknowledge an uninstall successfully after swallowing an SDK error.
40
+
41
+ Retries are **not durable across process death**. To use your existing job/queue system:
42
+
43
+ ```ts
44
+ import { createIdentifyEvent, createUninstallEvent } from '@basezero/sdk';
45
+
46
+ const event = createIdentifyEvent(customer);
47
+ // Persist the envelope once; retry the same saved object after failures/restarts.
48
+ await basezero.send(event);
49
+
50
+ const removal = createUninstallEvent({ platformId: '123' }, {
51
+ eventId: 'shopify-uninstall:original-event-id',
52
+ occurredAt: '2026-01-01T00:00:00.000Z',
53
+ });
54
+ await basezero.send(removal);
55
+ ```
56
+
57
+ `BaseZeroError` exposes `code`, `status`, and the original `event` for recovery. The event contains customer data; don't log the whole error. Reusing an ID with different contents is a 409 conflict. Never regenerate an occurrence time for a retry.
58
+
59
+ `platformId` is a numeric string or Shopify/Partner Shop GID. Profiles accept `myshopifyDomain`, optional `name`, and optional `email`; no access token or arbitrary metadata. The raw envelope is `{ eventId, occurredAt, type, customer }`. Successful writes return `{ eventId, customerId, installationId }` after durable persistence.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ npm install @basezero/sdk
65
+ # or
66
+ pnpm add @basezero/sdk
67
+ ```
68
+
69
+ ## Build/install from this repository
70
+
71
+ For local development, from the BaseZero repository:
72
+
73
+ ```bash
74
+ npm pack ./packages/sdk --pack-destination /tmp
75
+ ```
76
+
77
+ Then in the app repository:
78
+
79
+ ```bash
80
+ npm install /tmp/basezero-sdk-0.1.0.tgz
81
+ ```
82
+
83
+ Provisioning, API reads, storage semantics and limitations: `docs/app-events.md` in the BaseZero repository.
@@ -0,0 +1,42 @@
1
+ export type IdentifyInput = {
2
+ platformId: string;
3
+ myshopifyDomain: string;
4
+ name?: string;
5
+ email?: string;
6
+ };
7
+ export type UninstallInput = {
8
+ platformId: string;
9
+ };
10
+ export type EventDelivery = {
11
+ eventId: string;
12
+ occurredAt: string;
13
+ };
14
+ export type AppEvent = EventDelivery & ({
15
+ type: "identify";
16
+ customer: IdentifyInput;
17
+ } | {
18
+ type: "uninstall";
19
+ customer: UninstallInput;
20
+ });
21
+ export type EventReceipt = {
22
+ eventId: string;
23
+ customerId: string;
24
+ installationId: string;
25
+ };
26
+ export type AppCustomer = {
27
+ customerId: string;
28
+ installationId: string;
29
+ firstSeenAt: string;
30
+ status: "installed" | "uninstalled";
31
+ lastObservedAt: string;
32
+ events: Array<{
33
+ eventId: string;
34
+ platformEventId: string;
35
+ rawAppEventId: string;
36
+ netChange: null;
37
+ ignored: number;
38
+ type: "installed" | "reinstalled" | "uninstalled";
39
+ occurredAt: string;
40
+ quality: "inferred" | "confirmed";
41
+ }>;
42
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import type { AppEvent, EventDelivery, EventReceipt, IdentifyInput, UninstallInput } from "./contracts.js";
2
+ export type { AppCustomer, AppEvent, EventDelivery, EventReceipt, IdentifyInput, UninstallInput, } from "./contracts.js";
3
+ export type BaseZeroOptions = {
4
+ baseUrl: string;
5
+ apiKey: string;
6
+ fetch?: typeof fetch;
7
+ attempts?: number;
8
+ timeoutMs?: number;
9
+ };
10
+ export declare class BaseZeroError extends Error {
11
+ readonly code: string;
12
+ readonly status: number | undefined;
13
+ readonly event: AppEvent;
14
+ constructor(input: {
15
+ code: string;
16
+ event: AppEvent;
17
+ status?: number;
18
+ });
19
+ }
20
+ export declare function createIdentifyEvent(customer: IdentifyInput, delivery?: Partial<EventDelivery>): AppEvent;
21
+ export declare function createUninstallEvent(customer: UninstallInput, delivery: EventDelivery): AppEvent;
22
+ export declare class BaseZero {
23
+ #private;
24
+ constructor(options: BaseZeroOptions);
25
+ identify(customer: IdentifyInput, delivery?: Partial<EventDelivery>): Promise<EventReceipt>;
26
+ uninstall(customer: UninstallInput, delivery: EventDelivery): Promise<EventReceipt>;
27
+ send(event: AppEvent): Promise<EventReceipt>;
28
+ }
package/dist/index.js ADDED
@@ -0,0 +1,142 @@
1
+ const DEFAULT_ATTEMPTS = 3;
2
+ const DEFAULT_TIMEOUT_MS = 5_000;
3
+ const RETRY_DELAY_MS = 250;
4
+ export class BaseZeroError extends Error {
5
+ code;
6
+ status;
7
+ event;
8
+ constructor(input) {
9
+ super(`BaseZero: ${input.code}`);
10
+ this.name = "BaseZeroError";
11
+ this.code = input.code;
12
+ this.status = input.status;
13
+ this.event = input.event;
14
+ }
15
+ }
16
+ export function createIdentifyEvent(customer, delivery = {}) {
17
+ return {
18
+ eventId: delivery.eventId ?? crypto.randomUUID(),
19
+ occurredAt: delivery.occurredAt ?? new Date().toISOString(),
20
+ type: "identify",
21
+ customer: { ...customer },
22
+ };
23
+ }
24
+ export function createUninstallEvent(customer, delivery) {
25
+ return {
26
+ eventId: delivery.eventId,
27
+ occurredAt: delivery.occurredAt,
28
+ type: "uninstall",
29
+ customer: { ...customer },
30
+ };
31
+ }
32
+ export class BaseZero {
33
+ #url;
34
+ #apiKey;
35
+ #fetch;
36
+ #attempts;
37
+ #timeoutMs;
38
+ constructor(options) {
39
+ if (typeof window !== "undefined")
40
+ throw new Error("BaseZero SDK is server-only");
41
+ const url = new URL(options.baseUrl);
42
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
43
+ if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) ||
44
+ url.username ||
45
+ url.password ||
46
+ url.search ||
47
+ url.hash ||
48
+ url.pathname !== "/")
49
+ throw new Error("baseUrl must be an HTTPS origin (HTTP allowed on localhost)");
50
+ if (!/^bz_[a-f0-9]{64}$/.test(options.apiKey))
51
+ throw new Error("Invalid BaseZero API key");
52
+ this.#url = new URL("/api/app/v1/events", url).href;
53
+ this.#apiKey = options.apiKey;
54
+ this.#fetch = options.fetch ?? globalThis.fetch;
55
+ this.#attempts = options.attempts ?? DEFAULT_ATTEMPTS;
56
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
57
+ if (!Number.isInteger(this.#attempts) ||
58
+ this.#attempts < 1 ||
59
+ this.#attempts > 5)
60
+ throw new Error("attempts must be between 1 and 5");
61
+ if (!Number.isFinite(this.#timeoutMs) ||
62
+ this.#timeoutMs <= 0 ||
63
+ this.#timeoutMs > 30_000)
64
+ throw new Error("timeoutMs must be between 1 and 30000");
65
+ }
66
+ identify(customer, delivery) {
67
+ return this.send(createIdentifyEvent(customer, delivery));
68
+ }
69
+ uninstall(customer, delivery) {
70
+ return this.send(createUninstallEvent(customer, delivery));
71
+ }
72
+ async send(event) {
73
+ const snapshot = structuredClone(event);
74
+ const body = JSON.stringify(snapshot);
75
+ for (let attempt = 0; attempt < this.#attempts; attempt++) {
76
+ try {
77
+ return await this.#attempt({ event: snapshot, body });
78
+ }
79
+ catch (error) {
80
+ if (!(error instanceof BaseZeroError))
81
+ throw error;
82
+ const retryable = error.status === undefined ||
83
+ error.status === 408 ||
84
+ error.status === 429 ||
85
+ error.status >= 500;
86
+ if (!retryable || attempt === this.#attempts - 1)
87
+ throw error;
88
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * 2 ** attempt));
89
+ }
90
+ }
91
+ throw new BaseZeroError({ code: "delivery_failed", event: snapshot });
92
+ }
93
+ async #attempt(input) {
94
+ const controller = new AbortController();
95
+ const timeout = setTimeout(() => controller.abort(), this.#timeoutMs);
96
+ try {
97
+ const response = await this.#fetch(this.#url, {
98
+ method: "POST",
99
+ redirect: "error",
100
+ signal: controller.signal,
101
+ headers: {
102
+ Authorization: `Bearer ${this.#apiKey}`,
103
+ "Content-Type": "application/json",
104
+ },
105
+ body: input.body,
106
+ });
107
+ if (!response.ok) {
108
+ await response.body?.cancel();
109
+ throw new BaseZeroError({
110
+ code: `http_${response.status}`,
111
+ status: response.status,
112
+ event: input.event,
113
+ });
114
+ }
115
+ const receipt = await response.json();
116
+ if (!isReceipt(receipt) || receipt.eventId !== input.event.eventId)
117
+ throw new BaseZeroError({
118
+ code: "invalid_response",
119
+ event: input.event,
120
+ });
121
+ return receipt;
122
+ }
123
+ catch (error) {
124
+ if (error instanceof BaseZeroError)
125
+ throw error;
126
+ throw new BaseZeroError({ code: "network_error", event: input.event });
127
+ }
128
+ finally {
129
+ clearTimeout(timeout);
130
+ }
131
+ }
132
+ }
133
+ function isReceipt(value) {
134
+ return (typeof value === "object" &&
135
+ value !== null &&
136
+ "eventId" in value &&
137
+ typeof value.eventId === "string" &&
138
+ "customerId" in value &&
139
+ typeof value.customerId === "string" &&
140
+ "installationId" in value &&
141
+ typeof value.installationId === "string");
142
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@basezero/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Server-side identify and uninstall events for BaseZero",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
9
+ },
10
+ "files": ["dist", "README.md"],
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org/"
14
+ },
15
+ "scripts": { "build": "tsc -p tsconfig.json", "prepack": "npm run build" },
16
+ "engines": { "node": ">=20" }
17
+ }