@customyai/customy-sdk 0.1.0 → 0.2.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/LICENSE +21 -0
- package/dist/chunk-2TLSPDBG.mjs +184 -0
- package/dist/chunk-CE5MONHF.cjs +191 -0
- package/dist/{index.js → index.cjs} +26 -206
- package/dist/{index.d.mts → index.d.cts} +3 -74
- package/dist/index.d.ts +3 -74
- package/dist/index.mjs +3 -176
- package/dist/portfolio-Bk9cn8h4.d.cts +79 -0
- package/dist/portfolio-Bk9cn8h4.d.ts +79 -0
- package/dist/server.cjs +432 -0
- package/dist/server.d.cts +164 -0
- package/dist/server.d.ts +164 -0
- package/dist/server.mjs +430 -0
- package/package.json +69 -26
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { CustomyPlatformConfiguration, MachineTokenProvider } from '@customyai/customy-access/server';
|
|
2
|
+
import { CustomySend } from '@customyai/send-sdk';
|
|
3
|
+
import { CustomyLinks } from '@customyai/links-sdk';
|
|
4
|
+
import { k as CustomySdkKey, e as CustomyProductClient } from './portfolio-Bk9cn8h4.js';
|
|
5
|
+
|
|
6
|
+
type CustomerDataEventType = "track" | "identify" | "group" | "page" | "screen" | "alias";
|
|
7
|
+
interface CustomerDataEvent {
|
|
8
|
+
messageId?: string;
|
|
9
|
+
type: CustomerDataEventType;
|
|
10
|
+
event?: string;
|
|
11
|
+
userId?: string;
|
|
12
|
+
anonymousId?: string;
|
|
13
|
+
groupId?: string;
|
|
14
|
+
timestamp?: string | Date;
|
|
15
|
+
schemaVersion?: string;
|
|
16
|
+
properties?: Record<string, unknown>;
|
|
17
|
+
traits?: Record<string, unknown>;
|
|
18
|
+
context?: Record<string, unknown>;
|
|
19
|
+
consent?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
interface CustomerDataClientConfig {
|
|
22
|
+
collectUrl: string;
|
|
23
|
+
/** Write key de la fuente (navegador y apps sin identidad de servidor). */
|
|
24
|
+
writeKey?: string;
|
|
25
|
+
/**
|
|
26
|
+
* En servidor, en lugar del write key: un token de máquina de Customy Access
|
|
27
|
+
* con audiencia `customy-data` y scope `data:collect` (la identidad única de
|
|
28
|
+
* la app; `createMachineTokenProvider` de `@customyai/customy-access/server`).
|
|
29
|
+
* Data resuelve la fuente de la app a partir de los claims firmados.
|
|
30
|
+
*/
|
|
31
|
+
accessToken?: () => Promise<string>;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
maxRetries?: number;
|
|
34
|
+
retryBaseMs?: number;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
maxBatchSize?: number;
|
|
37
|
+
maxQueueSize?: number;
|
|
38
|
+
redactFields?: string[];
|
|
39
|
+
beforeSend?: (event: CustomerDataEvent) => CustomerDataEvent | null;
|
|
40
|
+
now?: () => Date;
|
|
41
|
+
idFactory?: () => string;
|
|
42
|
+
onError?: (error: Error, events: CustomerDataEvent[]) => void;
|
|
43
|
+
}
|
|
44
|
+
interface CustomerDataBatchResponse {
|
|
45
|
+
accepted: number;
|
|
46
|
+
deduplicated: number;
|
|
47
|
+
quarantined: number;
|
|
48
|
+
results: Array<Record<string, unknown>>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Public/browser-safe customer data client. Tenant scope is derived from the
|
|
52
|
+
* source write key by customy-data; callers cannot select organization,
|
|
53
|
+
* project or environment in the payload.
|
|
54
|
+
*/
|
|
55
|
+
declare class CustomyCustomerDataClient {
|
|
56
|
+
private readonly config;
|
|
57
|
+
private readonly fetchImpl;
|
|
58
|
+
private readonly maxRetries;
|
|
59
|
+
private readonly retryBaseMs;
|
|
60
|
+
private readonly timeoutMs;
|
|
61
|
+
private readonly maxBatchSize;
|
|
62
|
+
private readonly maxQueueSize;
|
|
63
|
+
private readonly redactFields;
|
|
64
|
+
private readonly now;
|
|
65
|
+
private readonly idFactory;
|
|
66
|
+
private queue;
|
|
67
|
+
private flushing;
|
|
68
|
+
private inFlightCount;
|
|
69
|
+
constructor(config: CustomerDataClientConfig);
|
|
70
|
+
event(input: CustomerDataEvent): CustomerDataEvent;
|
|
71
|
+
enqueue(input: CustomerDataEvent): number;
|
|
72
|
+
send(input: CustomerDataEvent): Promise<Record<string, unknown>>;
|
|
73
|
+
track(event: string, properties: Record<string, unknown>, identity: Pick<CustomerDataEvent, "userId" | "anonymousId" | "groupId" | "context" | "consent">): Promise<Record<string, unknown>>;
|
|
74
|
+
identify(traits: Record<string, unknown>, identity: Pick<CustomerDataEvent, "userId" | "anonymousId" | "context" | "consent">): Promise<Record<string, unknown>>;
|
|
75
|
+
group(traits: Record<string, unknown>, identity: Pick<CustomerDataEvent, "groupId" | "userId" | "anonymousId" | "context" | "consent">): Promise<Record<string, unknown>>;
|
|
76
|
+
page(properties: Record<string, unknown>, identity: Pick<CustomerDataEvent, "userId" | "anonymousId" | "context" | "consent">): Promise<Record<string, unknown>>;
|
|
77
|
+
screen(properties: Record<string, unknown>, identity: Pick<CustomerDataEvent, "userId" | "anonymousId" | "context" | "consent">): Promise<Record<string, unknown>>;
|
|
78
|
+
alias(userId: string, previousId: string, options?: Pick<CustomerDataEvent, "context" | "consent">): Promise<Record<string, unknown>>;
|
|
79
|
+
flush(): Promise<CustomerDataBatchResponse>;
|
|
80
|
+
private request;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Consumo de una app en Customy Billing con su identidad de Customy Access
|
|
85
|
+
* (`POST /v1/apps/usage`). El token de máquina, con audiencia `customy-billing`
|
|
86
|
+
* y scope `billing:usage:report`, fija la organización y la app: el cuerpo solo
|
|
87
|
+
* lleva el meter, la cantidad y una clave de idempotencia por evento.
|
|
88
|
+
*/
|
|
89
|
+
type AppUsageEvent = {
|
|
90
|
+
/** Meter declarado por la app, p. ej. `coach.runs`. */
|
|
91
|
+
meter: string;
|
|
92
|
+
quantity: number;
|
|
93
|
+
/** Única por evento: reintentar con la misma clave no suma dos veces. */
|
|
94
|
+
idempotencyKey: string;
|
|
95
|
+
occurredAt?: string | Date;
|
|
96
|
+
};
|
|
97
|
+
type AppUsageResult = {
|
|
98
|
+
accepted: number;
|
|
99
|
+
events: Array<{
|
|
100
|
+
meter: string;
|
|
101
|
+
idempotencyKey: string;
|
|
102
|
+
id: string;
|
|
103
|
+
deduplicated: boolean;
|
|
104
|
+
}>;
|
|
105
|
+
};
|
|
106
|
+
type AppUsageClientConfig = {
|
|
107
|
+
/** URL de Billing del entorno (discovery: `products.billing.base_url`). */
|
|
108
|
+
baseUrl: string;
|
|
109
|
+
/** Proveedor de tokens de Access (`createMachineTokenProvider` de `@customyai/customy-access/server`). */
|
|
110
|
+
accessToken: () => Promise<string>;
|
|
111
|
+
fetch?: typeof fetch;
|
|
112
|
+
timeoutMs?: number;
|
|
113
|
+
};
|
|
114
|
+
declare class AppUsageClient {
|
|
115
|
+
private readonly config;
|
|
116
|
+
private readonly baseUrl;
|
|
117
|
+
private readonly fetchImpl;
|
|
118
|
+
private readonly timeoutMs;
|
|
119
|
+
constructor(config: AppUsageClientConfig);
|
|
120
|
+
/** Reporta hasta 100 eventos de consumo en una llamada. */
|
|
121
|
+
report(events: AppUsageEvent[]): Promise<AppUsageResult>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @customyai/customy-sdk/server — `createCustomy()`: una sola identidad de app para todo el ecosistema.
|
|
126
|
+
*
|
|
127
|
+
* Lee el discovery del issuer (`/.well-known/customy-configuration`), crea un
|
|
128
|
+
* proveedor de tokens de máquina por audiencia de producto y entrega los
|
|
129
|
+
* clientes de cada producto ya apuntando a su URL y con su proveedor. No añade
|
|
130
|
+
* transporte propio: compone los SDK de cada servicio. Solo para servidor —
|
|
131
|
+
* usa el secreto de la app.
|
|
132
|
+
*
|
|
133
|
+
* const customy = await createCustomy({ issuer, clientId, clientSecret });
|
|
134
|
+
* await customy.send.emails.send({ ... });
|
|
135
|
+
* await customy.billing.report([{ meter: "coach.runs", quantity: 1, idempotencyKey }]);
|
|
136
|
+
* await customy.product("crm").get("/v1/contacts");
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
type CreateCustomyOptions = {
|
|
140
|
+
/** Issuer de Customy Access del entorno, p. ej. `https://access-api.customy.ai`. */
|
|
141
|
+
issuer: string;
|
|
142
|
+
clientId: string;
|
|
143
|
+
clientSecret: string;
|
|
144
|
+
/** Scopes por producto (clave del discovery). Sin entrada, los del cliente. */
|
|
145
|
+
scopes?: Partial<Record<string, readonly string[]>>;
|
|
146
|
+
fetch?: typeof fetch;
|
|
147
|
+
timeoutMs?: number;
|
|
148
|
+
/** Opciones extra del cliente de Data (colas, redacción, `onError`…). */
|
|
149
|
+
data?: Omit<CustomerDataClientConfig, "collectUrl" | "writeKey" | "accessToken" | "fetchImpl">;
|
|
150
|
+
};
|
|
151
|
+
type Customy = {
|
|
152
|
+
readonly platform: CustomyPlatformConfiguration;
|
|
153
|
+
/** Proveedor de tokens de máquina para la audiencia de un producto del discovery. */
|
|
154
|
+
token(product: string): MachineTokenProvider;
|
|
155
|
+
/** Cliente HTTP genérico de un producto, con su URL y su proveedor de tokens. */
|
|
156
|
+
product(product: CustomySdkKey): CustomyProductClient;
|
|
157
|
+
readonly send: CustomySend;
|
|
158
|
+
readonly links: CustomyLinks;
|
|
159
|
+
readonly data: CustomyCustomerDataClient;
|
|
160
|
+
readonly billing: AppUsageClient;
|
|
161
|
+
};
|
|
162
|
+
declare function createCustomy(options: CreateCustomyOptions): Promise<Customy>;
|
|
163
|
+
|
|
164
|
+
export { type CreateCustomyOptions, type Customy, createCustomy };
|
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { CustomySdkError, CustomyProductClient } from './chunk-2TLSPDBG.mjs';
|
|
2
|
+
import { discoverPlatform, createMachineTokenProvider } from '@customyai/customy-access/server';
|
|
3
|
+
import { CustomySend } from '@customyai/send-sdk';
|
|
4
|
+
import { CustomyLinks } from '@customyai/links-sdk';
|
|
5
|
+
|
|
6
|
+
// src/vendor/sdk-data.ts
|
|
7
|
+
var CUSTOMY_DATA_SDK_VERSION = "0.3.1";
|
|
8
|
+
var DataSdkError = class extends Error {
|
|
9
|
+
constructor(message, statusCode, response) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.statusCode = statusCode;
|
|
12
|
+
this.response = response;
|
|
13
|
+
this.name = "DataSdkError";
|
|
14
|
+
}
|
|
15
|
+
statusCode;
|
|
16
|
+
response;
|
|
17
|
+
};
|
|
18
|
+
var CustomyCustomerDataClient = class {
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
if (!config.collectUrl || !config.writeKey && !config.accessToken) {
|
|
22
|
+
throw new Error("collectUrl and writeKey (or accessToken) are required");
|
|
23
|
+
}
|
|
24
|
+
const fetchImpl = config.fetchImpl ?? globalThis.fetch;
|
|
25
|
+
if (!fetchImpl) throw new Error("fetch is required. Pass fetchImpl in non-browser runtimes.");
|
|
26
|
+
this.fetchImpl = fetchImpl;
|
|
27
|
+
this.maxRetries = config.maxRetries ?? 3;
|
|
28
|
+
this.retryBaseMs = config.retryBaseMs ?? 250;
|
|
29
|
+
this.timeoutMs = config.timeoutMs ?? 1e4;
|
|
30
|
+
this.maxBatchSize = Math.min(1e3, Math.max(1, config.maxBatchSize ?? 100));
|
|
31
|
+
this.maxQueueSize = Math.max(1, config.maxQueueSize ?? 1e4);
|
|
32
|
+
this.redactFields = new Set(config.redactFields ?? []);
|
|
33
|
+
this.now = config.now ?? (() => /* @__PURE__ */ new Date());
|
|
34
|
+
this.idFactory = config.idFactory ?? randomId;
|
|
35
|
+
}
|
|
36
|
+
config;
|
|
37
|
+
fetchImpl;
|
|
38
|
+
maxRetries;
|
|
39
|
+
retryBaseMs;
|
|
40
|
+
timeoutMs;
|
|
41
|
+
maxBatchSize;
|
|
42
|
+
maxQueueSize;
|
|
43
|
+
redactFields;
|
|
44
|
+
now;
|
|
45
|
+
idFactory;
|
|
46
|
+
queue = [];
|
|
47
|
+
flushing = false;
|
|
48
|
+
inFlightCount = 0;
|
|
49
|
+
event(input) {
|
|
50
|
+
rejectCustomerDataTenantFields(input);
|
|
51
|
+
let normalized = {
|
|
52
|
+
...input,
|
|
53
|
+
messageId: input.messageId ?? this.idFactory(),
|
|
54
|
+
timestamp: normalizeDate(input.timestamp ?? this.now()),
|
|
55
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
56
|
+
properties: input.properties ?? {},
|
|
57
|
+
traits: input.traits ?? {},
|
|
58
|
+
context: {
|
|
59
|
+
...input.context,
|
|
60
|
+
library: {
|
|
61
|
+
name: "./sdk-data",
|
|
62
|
+
version: CUSTOMY_DATA_SDK_VERSION
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
consent: input.consent ?? {}
|
|
66
|
+
};
|
|
67
|
+
validateCustomerDataEvent(normalized);
|
|
68
|
+
normalized = redactCustomerDataEvent(normalized, this.redactFields);
|
|
69
|
+
if (this.config.beforeSend) {
|
|
70
|
+
const candidate = this.config.beforeSend(cloneCustomerDataEvent(normalized));
|
|
71
|
+
if (!candidate) throw new Error("Event blocked by beforeSend");
|
|
72
|
+
rejectCustomerDataTenantFields(candidate);
|
|
73
|
+
normalized = redactCustomerDataEvent(candidate, this.redactFields);
|
|
74
|
+
validateCustomerDataEvent(normalized);
|
|
75
|
+
}
|
|
76
|
+
return normalized;
|
|
77
|
+
}
|
|
78
|
+
enqueue(input) {
|
|
79
|
+
if (this.queue.length + this.inFlightCount >= this.maxQueueSize) {
|
|
80
|
+
throw new Error("Customer data queue is full");
|
|
81
|
+
}
|
|
82
|
+
const normalized = this.event(input);
|
|
83
|
+
if (this.queue.length + this.inFlightCount >= this.maxQueueSize) {
|
|
84
|
+
throw new Error("Customer data queue is full");
|
|
85
|
+
}
|
|
86
|
+
this.queue.push(normalized);
|
|
87
|
+
return this.queue.length;
|
|
88
|
+
}
|
|
89
|
+
async send(input) {
|
|
90
|
+
return this.request("event", this.event(input));
|
|
91
|
+
}
|
|
92
|
+
async track(event, properties, identity) {
|
|
93
|
+
return this.send({ type: "track", event, properties, ...identity });
|
|
94
|
+
}
|
|
95
|
+
async identify(traits, identity) {
|
|
96
|
+
return this.send({ type: "identify", traits, ...identity });
|
|
97
|
+
}
|
|
98
|
+
async group(traits, identity) {
|
|
99
|
+
return this.send({ type: "group", traits, ...identity });
|
|
100
|
+
}
|
|
101
|
+
async page(properties, identity) {
|
|
102
|
+
return this.send({ type: "page", properties, ...identity });
|
|
103
|
+
}
|
|
104
|
+
async screen(properties, identity) {
|
|
105
|
+
return this.send({ type: "screen", properties, ...identity });
|
|
106
|
+
}
|
|
107
|
+
async alias(userId, previousId, options = {}) {
|
|
108
|
+
return this.send({
|
|
109
|
+
type: "alias",
|
|
110
|
+
userId,
|
|
111
|
+
anonymousId: previousId,
|
|
112
|
+
properties: { previousId },
|
|
113
|
+
...options
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
async flush() {
|
|
117
|
+
if (this.flushing) throw new Error("A customer data flush is already in progress");
|
|
118
|
+
if (this.queue.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
accepted: 0,
|
|
121
|
+
deduplicated: 0,
|
|
122
|
+
quarantined: 0,
|
|
123
|
+
results: []
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
this.flushing = true;
|
|
127
|
+
const pending = this.queue;
|
|
128
|
+
this.inFlightCount = pending.length;
|
|
129
|
+
this.queue = [];
|
|
130
|
+
const aggregate = {
|
|
131
|
+
accepted: 0,
|
|
132
|
+
deduplicated: 0,
|
|
133
|
+
quarantined: 0,
|
|
134
|
+
results: []
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
for (let offset = 0; offset < pending.length; offset += this.maxBatchSize) {
|
|
138
|
+
const batch = pending.slice(offset, offset + this.maxBatchSize);
|
|
139
|
+
const response = await this.request("batch", {
|
|
140
|
+
batch
|
|
141
|
+
});
|
|
142
|
+
aggregate.accepted += Number(response.accepted ?? 0);
|
|
143
|
+
aggregate.deduplicated += Number(response.deduplicated ?? 0);
|
|
144
|
+
aggregate.quarantined += Number(response.quarantined ?? 0);
|
|
145
|
+
aggregate.results.push(...response.results ?? []);
|
|
146
|
+
}
|
|
147
|
+
this.flushing = false;
|
|
148
|
+
this.inFlightCount = 0;
|
|
149
|
+
return aggregate;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
this.queue = [...pending, ...this.queue];
|
|
152
|
+
this.flushing = false;
|
|
153
|
+
this.inFlightCount = 0;
|
|
154
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
155
|
+
this.config.onError?.(normalized, pending);
|
|
156
|
+
throw normalized;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async request(path, body) {
|
|
160
|
+
let lastError;
|
|
161
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
|
|
162
|
+
const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
|
|
163
|
+
const timer = controller ? setTimeout(() => controller.abort(), this.timeoutMs) : void 0;
|
|
164
|
+
try {
|
|
165
|
+
const response = await this.fetchImpl(`${this.config.collectUrl.replace(/\/$/, "")}/v1/collect/${path}`, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: {
|
|
168
|
+
"content-type": "application/json",
|
|
169
|
+
...this.config.accessToken ? { authorization: `Bearer ${await this.config.accessToken()}` } : { "x-write-key": this.config.writeKey }
|
|
170
|
+
},
|
|
171
|
+
body: JSON.stringify(body),
|
|
172
|
+
signal: controller?.signal
|
|
173
|
+
});
|
|
174
|
+
const text = await response.text();
|
|
175
|
+
const parsed = text ? safeJson(text) : {};
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
throw new DataSdkError(`Customy customer data collection failed with HTTP ${response.status}`, response.status, parsed);
|
|
178
|
+
}
|
|
179
|
+
validateCustomerDataAcknowledgement(
|
|
180
|
+
path,
|
|
181
|
+
parsed,
|
|
182
|
+
path === "batch" ? body.batch.length : 1,
|
|
183
|
+
response.status
|
|
184
|
+
);
|
|
185
|
+
return parsed;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
188
|
+
if (!isRetryableError(lastError) || attempt >= this.maxRetries) throw lastError;
|
|
189
|
+
await sleep(this.retryBaseMs * Math.pow(2, attempt));
|
|
190
|
+
} finally {
|
|
191
|
+
if (timer) clearTimeout(timer);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
throw lastError ?? new Error("Unknown customer data collection failure");
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
function isRecord(value) {
|
|
198
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
199
|
+
}
|
|
200
|
+
function collectionOutcome(value) {
|
|
201
|
+
if (!isRecord(value)) return void 0;
|
|
202
|
+
const hasId = (id) => typeof id === "string" && id.trim().length > 0;
|
|
203
|
+
if (value.quarantined === true) {
|
|
204
|
+
return value.accepted === false && value.deduplicated === false && hasId(value.quarantineId) ? "quarantined" : void 0;
|
|
205
|
+
}
|
|
206
|
+
if (value.quarantined !== void 0 && value.quarantined !== false) return void 0;
|
|
207
|
+
if (!hasId(value.eventId)) return void 0;
|
|
208
|
+
if (value.accepted === true && value.deduplicated === false) return "accepted";
|
|
209
|
+
if (value.accepted === false && value.deduplicated === true) return "deduplicated";
|
|
210
|
+
return void 0;
|
|
211
|
+
}
|
|
212
|
+
function validateCustomerDataAcknowledgement(path, value, expectedCount, status) {
|
|
213
|
+
const reject = () => {
|
|
214
|
+
throw new DataSdkError("Customy customer data collection returned an invalid acknowledgement", status);
|
|
215
|
+
};
|
|
216
|
+
if (path === "event") {
|
|
217
|
+
const outcome = collectionOutcome(value);
|
|
218
|
+
if (outcome !== "accepted" && outcome !== "deduplicated") reject();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!isRecord(value) || !Array.isArray(value.results) || value.results.length !== expectedCount) return reject();
|
|
222
|
+
const counts = { accepted: 0, deduplicated: 0, quarantined: 0 };
|
|
223
|
+
for (const result of value.results) {
|
|
224
|
+
const outcome = collectionOutcome(result);
|
|
225
|
+
if (!outcome) return reject();
|
|
226
|
+
counts[outcome] += 1;
|
|
227
|
+
}
|
|
228
|
+
for (const key of ["accepted", "deduplicated", "quarantined"]) {
|
|
229
|
+
if (!Number.isSafeInteger(value[key]) || value[key] !== counts[key]) reject();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
var CUSTOMER_DATA_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
233
|
+
"track",
|
|
234
|
+
"identify",
|
|
235
|
+
"group",
|
|
236
|
+
"page",
|
|
237
|
+
"screen",
|
|
238
|
+
"alias"
|
|
239
|
+
]);
|
|
240
|
+
var FORBIDDEN_CUSTOMER_DATA_TENANT_FIELDS = [
|
|
241
|
+
"tenantId",
|
|
242
|
+
"organizationId",
|
|
243
|
+
"projectId",
|
|
244
|
+
"environmentId"
|
|
245
|
+
];
|
|
246
|
+
function validateCustomerDataEvent(event) {
|
|
247
|
+
if (!CUSTOMER_DATA_EVENT_TYPES.has(event.type)) {
|
|
248
|
+
throw new Error("Type must be track, identify, group, page, screen or alias");
|
|
249
|
+
}
|
|
250
|
+
if (!event.userId && !event.anonymousId && !event.groupId) {
|
|
251
|
+
throw new Error("At least one userId, anonymousId or groupId is required");
|
|
252
|
+
}
|
|
253
|
+
if (event.type === "track" && !event.event) {
|
|
254
|
+
throw new Error("Track calls require an event name");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function rejectCustomerDataTenantFields(event) {
|
|
258
|
+
const payload = event;
|
|
259
|
+
const forbidden = FORBIDDEN_CUSTOMER_DATA_TENANT_FIELDS.filter(
|
|
260
|
+
(key) => Object.prototype.hasOwnProperty.call(payload, key)
|
|
261
|
+
);
|
|
262
|
+
if (forbidden.length > 0) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Tenant scope is derived from the write key; forbidden fields: ${forbidden.join(", ")}`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function cloneCustomerDataEvent(event) {
|
|
269
|
+
return JSON.parse(JSON.stringify(event));
|
|
270
|
+
}
|
|
271
|
+
function redactCustomerDataEvent(event, fields) {
|
|
272
|
+
return redactCustomerDataValue(event, fields);
|
|
273
|
+
}
|
|
274
|
+
function redactCustomerDataValue(value, fields) {
|
|
275
|
+
if (value instanceof Date) return value.toISOString();
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
return value.map((entry) => redactCustomerDataValue(entry, fields));
|
|
278
|
+
}
|
|
279
|
+
if (value && typeof value === "object") {
|
|
280
|
+
return Object.fromEntries(
|
|
281
|
+
Object.entries(value).map(([key, entry]) => [
|
|
282
|
+
key,
|
|
283
|
+
fields.has(key) ? "[REDACTED]" : redactCustomerDataValue(entry, fields)
|
|
284
|
+
])
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return value;
|
|
288
|
+
}
|
|
289
|
+
function normalizeDate(value) {
|
|
290
|
+
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
291
|
+
}
|
|
292
|
+
function isRetryableError(error) {
|
|
293
|
+
if (error instanceof DataSdkError) {
|
|
294
|
+
return !error.statusCode || error.statusCode >= 500 || error.statusCode === 429;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
function sleep(ms) {
|
|
299
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
300
|
+
}
|
|
301
|
+
function randomId() {
|
|
302
|
+
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
|
303
|
+
if (globalThis.crypto?.getRandomValues) {
|
|
304
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
305
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
306
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
307
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0"));
|
|
308
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
|
309
|
+
}
|
|
310
|
+
return `evt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
311
|
+
}
|
|
312
|
+
function safeJson(text) {
|
|
313
|
+
try {
|
|
314
|
+
return JSON.parse(text);
|
|
315
|
+
} catch {
|
|
316
|
+
return { raw: text };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/vendor/app-usage.ts
|
|
321
|
+
var AppUsageError = class extends Error {
|
|
322
|
+
constructor(status, code, message) {
|
|
323
|
+
super(message);
|
|
324
|
+
this.status = status;
|
|
325
|
+
this.code = code;
|
|
326
|
+
this.name = "AppUsageError";
|
|
327
|
+
}
|
|
328
|
+
status;
|
|
329
|
+
code;
|
|
330
|
+
};
|
|
331
|
+
var AppUsageClient = class {
|
|
332
|
+
constructor(config) {
|
|
333
|
+
this.config = config;
|
|
334
|
+
if (!config?.baseUrl || typeof config.accessToken !== "function") throw new Error("AppUsageClient: baseUrl and accessToken are required");
|
|
335
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
336
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
|
337
|
+
this.timeoutMs = config.timeoutMs ?? 1e4;
|
|
338
|
+
}
|
|
339
|
+
config;
|
|
340
|
+
baseUrl;
|
|
341
|
+
fetchImpl;
|
|
342
|
+
timeoutMs;
|
|
343
|
+
/** Reporta hasta 100 eventos de consumo en una llamada. */
|
|
344
|
+
async report(events) {
|
|
345
|
+
if (events.length === 0 || events.length > 100) throw new Error("AppUsageClient: between 1 and 100 events per call");
|
|
346
|
+
const response = await this.fetchImpl(`${this.baseUrl}/v1/apps/usage`, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
headers: { authorization: `Bearer ${await this.config.accessToken()}`, "content-type": "application/json", accept: "application/json" },
|
|
349
|
+
body: JSON.stringify({
|
|
350
|
+
events: events.map((event) => ({
|
|
351
|
+
meter: event.meter,
|
|
352
|
+
quantity: event.quantity,
|
|
353
|
+
idempotencyKey: event.idempotencyKey,
|
|
354
|
+
...event.occurredAt ? { occurredAt: new Date(event.occurredAt).toISOString() } : {}
|
|
355
|
+
}))
|
|
356
|
+
}),
|
|
357
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
358
|
+
});
|
|
359
|
+
const body = await response.json().catch(() => ({}));
|
|
360
|
+
if (!response.ok) throw new AppUsageError(response.status, String(body.error ?? `HTTP_${response.status}`), String(body.message ?? "Usage report failed"));
|
|
361
|
+
return body;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// src/server.ts
|
|
366
|
+
async function createCustomy(options) {
|
|
367
|
+
if (!options.clientId || !options.clientSecret) throw new Error("CUSTOMY_MACHINE_CREDENTIALS_REQUIRED");
|
|
368
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
369
|
+
const platform = await discoverPlatform(options.issuer, fetchImpl);
|
|
370
|
+
const providers = /* @__PURE__ */ new Map();
|
|
371
|
+
const clients = /* @__PURE__ */ new Map();
|
|
372
|
+
function entry(product) {
|
|
373
|
+
const found = platform.products[product];
|
|
374
|
+
if (!found) throw new Error(`CUSTOMY_PRODUCT_NOT_DISCOVERED: ${product}`);
|
|
375
|
+
return found;
|
|
376
|
+
}
|
|
377
|
+
function token(product) {
|
|
378
|
+
let provider = providers.get(product);
|
|
379
|
+
if (!provider) {
|
|
380
|
+
provider = createMachineTokenProvider({
|
|
381
|
+
issuer: platform.issuer,
|
|
382
|
+
tokenEndpoint: platform.tokenEndpoint,
|
|
383
|
+
clientId: options.clientId,
|
|
384
|
+
clientSecret: options.clientSecret,
|
|
385
|
+
audience: entry(product).audience,
|
|
386
|
+
scopes: options.scopes?.[product],
|
|
387
|
+
fetch: fetchImpl
|
|
388
|
+
});
|
|
389
|
+
providers.set(product, provider);
|
|
390
|
+
}
|
|
391
|
+
return provider;
|
|
392
|
+
}
|
|
393
|
+
function once(key, build) {
|
|
394
|
+
if (!clients.has(key)) clients.set(key, build());
|
|
395
|
+
return clients.get(key);
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
platform,
|
|
399
|
+
token,
|
|
400
|
+
product(product) {
|
|
401
|
+
if (!platform.products[product]) throw new CustomySdkError(product, 0, "SDK_PRODUCT_NOT_DISCOVERED");
|
|
402
|
+
return once(`product:${product}`, () => new CustomyProductClient(product, {
|
|
403
|
+
baseUrl: entry(product).baseUrl,
|
|
404
|
+
accessToken: token(product),
|
|
405
|
+
fetch: options.fetch,
|
|
406
|
+
timeoutMs: options.timeoutMs
|
|
407
|
+
}));
|
|
408
|
+
},
|
|
409
|
+
get send() {
|
|
410
|
+
return once("send", () => new CustomySend(token("send"), { baseUrl: entry("send").baseUrl, fetch: options.fetch, timeoutMs: options.timeoutMs }));
|
|
411
|
+
},
|
|
412
|
+
get links() {
|
|
413
|
+
return once("links", () => new CustomyLinks({ apiKey: token("links"), baseUrl: entry("links").baseUrl, fetch: options.fetch, timeoutMs: options.timeoutMs }));
|
|
414
|
+
},
|
|
415
|
+
get data() {
|
|
416
|
+
return once("data", () => new CustomyCustomerDataClient({
|
|
417
|
+
...options.data,
|
|
418
|
+
collectUrl: entry("data").baseUrl,
|
|
419
|
+
accessToken: token("data"),
|
|
420
|
+
fetchImpl: options.fetch,
|
|
421
|
+
timeoutMs: options.timeoutMs
|
|
422
|
+
}));
|
|
423
|
+
},
|
|
424
|
+
get billing() {
|
|
425
|
+
return once("billing", () => new AppUsageClient({ baseUrl: entry("billing").baseUrl, accessToken: token("billing"), fetch: options.fetch, timeoutMs: options.timeoutMs }));
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export { createCustomy };
|