@volter/twin-segment 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 +154 -0
- package/client/segment-mirror.css +45 -0
- package/client/segment-mirror.tsx +154 -0
- package/dist/client/segment-mirror.bundle.js +342 -0
- package/dist/client/segment-mirror.css +45 -0
- package/dist/client/segment-mirror.d.ts +34 -0
- package/dist/client/segment-mirror.js +80 -0
- package/dist/client/segment-mirror.tsx +154 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +34 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +53 -0
- package/dist/src/segment-budget.d.ts +41 -0
- package/dist/src/segment-budget.js +112 -0
- package/dist/src/segment-capabilities.d.ts +12 -0
- package/dist/src/segment-capabilities.gen.d.ts +3 -0
- package/dist/src/segment-capabilities.gen.js +22 -0
- package/dist/src/segment-capabilities.js +907 -0
- package/dist/src/segment-conformance.d.ts +8 -0
- package/dist/src/segment-conformance.js +106 -0
- package/dist/src/segment-connector.d.ts +76 -0
- package/dist/src/segment-connector.js +226 -0
- package/dist/src/segment-mirror-ui.d.ts +42 -0
- package/dist/src/segment-mirror-ui.js +143 -0
- package/dist/src/segment-server.d.ts +25 -0
- package/dist/src/segment-server.js +90 -0
- package/dist/src/segment-surface.gen.d.ts +48 -0
- package/dist/src/segment-surface.gen.js +267 -0
- package/dist/src/segment-twin.d.ts +98 -0
- package/dist/src/segment-twin.js +543 -0
- package/package.json +59 -0
- package/src/cli.ts +30 -0
- package/src/index.ts +87 -0
- package/src/segment-budget.ts +138 -0
- package/src/segment-capabilities.gen.ts +25 -0
- package/src/segment-capabilities.ts +988 -0
- package/src/segment-conformance.ts +123 -0
- package/src/segment-connector.ts +233 -0
- package/src/segment-journey.uitest.ts +116 -0
- package/src/segment-mirror-ui.ts +157 -0
- package/src/segment-server.ts +95 -0
- package/src/segment-surface.gen.ts +277 -0
- package/src/segment-twin.ts +664 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { serveHttp } from '@volter/world-core';
|
|
2
|
+
import { dropped, events, groups, handleSegmentTwinRequest, identities } from "./segment-twin.js";
|
|
3
|
+
import { statefulTwinManifest, worldNow } from '@volter/world-core';
|
|
4
|
+
/** The keyless discovery door's body: a frozen VALUE, built once at module load from
|
|
5
|
+
* constants only. Nothing here reads the clock or state, so every serve of GET /twin is
|
|
6
|
+
* byte-identical (serve-path determinism, R9). */
|
|
7
|
+
const BASE_MANIFEST = statefulTwinManifest({
|
|
8
|
+
vendor: 'segment',
|
|
9
|
+
twinOf: 'the Segment HTTP Tracking API',
|
|
10
|
+
stores: 'tracked events, identify calls, group calls and refused (dropped) messages',
|
|
11
|
+
identity: 'Authenticate as Segment does — the write key as HTTP Basic username, an OAuth bearer, or `writeKey` in the body; the twin accepts any non-sentinel credential.',
|
|
12
|
+
});
|
|
13
|
+
/** THE STORE DOOR's projections (R5c). The Segment HTTP Tracking API is WRITE-ONLY — every route
|
|
14
|
+
* is an ingest and none of them reads a message back — so these four named projections are the
|
|
15
|
+
* only way to observe what was accepted, and `dropped` is the only way to see what was
|
|
16
|
+
* accepted-and-refused (Segment answers 200 and silently drops; this twin records the reason).
|
|
17
|
+
* They are also what makes this pack's determinism checkable at the resource level rather than
|
|
18
|
+
* only at the manifest door. */
|
|
19
|
+
const STORES = { events, identities, groups, dropped };
|
|
20
|
+
const STORE_NAMES = Object.keys(STORES).sort();
|
|
21
|
+
/** The manifest EDUCATES: it names the store door the way the kernel adapter does. */
|
|
22
|
+
const MANIFEST = {
|
|
23
|
+
...BASE_MANIFEST,
|
|
24
|
+
stores: STORE_NAMES,
|
|
25
|
+
doors: { ...BASE_MANIFEST.doors, store: 'GET /twin/store/<name>' },
|
|
26
|
+
};
|
|
27
|
+
/** The twin's HTTP face. Two things it must get right, both invisible to an in-process verify:
|
|
28
|
+
* 1. THE AUTHORIZATION HEADER IS PART OF THE PAYLOAD on this vendor. Two of Segment's three
|
|
29
|
+
* documented auth schemes live in that header (Basic with the write key as the username;
|
|
30
|
+
* OAuth Bearer), so a fetch that dropped it would make an analytics-node v1 client — which
|
|
31
|
+
* sends `auth: { username: writeKey }` and NO writeKey in the body — arrive anonymous.
|
|
32
|
+
* 2. An empty-body status must serve a genuinely EMPTY body: `Response.json(null)` puts the
|
|
33
|
+
* four bytes "null" on the wire (the A3-caught serialization class in
|
|
34
|
+
* var/line/mixpanel/REVIEW.md).
|
|
35
|
+
*
|
|
36
|
+
* FETCH-FIRST (runtime contract R12b): this is the pack's whole HTTP surface as a plain fetch,
|
|
37
|
+
* and `createSegmentTwinServer` is one line of `Bun.serve` around it. It is a CUSTOM fetch, not
|
|
38
|
+
* the kernel adapter (`createTwinFetchFromHandler`): the authorization threading (1) and the
|
|
39
|
+
* handler-throw guard (below) are this pack's own. The keyless `GET /twin` manifest door the
|
|
40
|
+
* adapter would have provided is mounted here by hand, answering the same
|
|
41
|
+
* `statefulTwinManifest` shape — discovery is a door every twin owes (serve-path determinism
|
|
42
|
+
* R9 replays it), and `/twin` shadows no Segment route: the Tracking API lives under `/v1/*`. */
|
|
43
|
+
export function createSegmentTwinFetch(options = {}) {
|
|
44
|
+
return async function segmentTwinFetch(request) {
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
const cleanPath = url.pathname.replace(/\/+$/, '') || '/';
|
|
48
|
+
// The keyless discovery door, before any vendor dispatch: a pure function of the
|
|
49
|
+
// manifest VALUE — no clock, no state — so replaying it is byte-identical (R9).
|
|
50
|
+
if (request.method === 'GET' && cleanPath === '/twin') {
|
|
51
|
+
return Response.json(MANIFEST);
|
|
52
|
+
}
|
|
53
|
+
// THE STORE DOOR (R5c) — see STORES above. Read-only, deterministic, served by the same
|
|
54
|
+
// fetch the vendor paths come out of; `/twin/*` shadows no Segment route (`/v1/*`).
|
|
55
|
+
if (request.method === 'GET' && cleanPath.startsWith('/twin/store/')) {
|
|
56
|
+
const name = cleanPath.slice('/twin/store/'.length);
|
|
57
|
+
const store = STORES[name];
|
|
58
|
+
if (!store)
|
|
59
|
+
return Response.json({ error: 'unknown store', store: name, stores: STORE_NAMES }, { status: 404 });
|
|
60
|
+
return Response.json(store(options.root));
|
|
61
|
+
}
|
|
62
|
+
const res = await handleSegmentTwinRequest({
|
|
63
|
+
method: request.method,
|
|
64
|
+
path: url.pathname + url.search,
|
|
65
|
+
body: request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.text(),
|
|
66
|
+
contentType: request.headers.get('content-type') ?? undefined,
|
|
67
|
+
authorization: request.headers.get('authorization') ?? undefined,
|
|
68
|
+
// The WORLD instant (R9): `receivedAt` and every minted messageId come from the world
|
|
69
|
+
// clock, never wall time.
|
|
70
|
+
occurredAt: worldNow(),
|
|
71
|
+
root: options.root,
|
|
72
|
+
});
|
|
73
|
+
if (res.body === null || res.body === undefined)
|
|
74
|
+
return new Response(null, { status: res.status, headers: res.headers });
|
|
75
|
+
return Response.json(res.body, { status: res.status, headers: res.headers });
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
// Never let a handler throw escape the fetch callback: under `bun test` that is reported as
|
|
79
|
+
// "unhandled between tests" and can stop later test registration, hiding the real defect
|
|
80
|
+
// (ADDING_A_TWIN §8). Answer the vendor's own error SHAPE.
|
|
81
|
+
return Response.json({ code: 'twin_internal_error', message: String(err) }, { status: 500 });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export async function createSegmentTwinServer(options = {}) {
|
|
86
|
+
return await serveHttp({
|
|
87
|
+
port: options.port ?? 0,
|
|
88
|
+
fetch: createSegmentTwinFetch(options),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type TwinOp = {
|
|
2
|
+
id: string;
|
|
3
|
+
area: string;
|
|
4
|
+
method: string;
|
|
5
|
+
path: string;
|
|
6
|
+
summary: string;
|
|
7
|
+
pathParams: string[];
|
|
8
|
+
requiredQuery: string[];
|
|
9
|
+
requiredBody: string[];
|
|
10
|
+
successStatus: number;
|
|
11
|
+
responseSkeleton: unknown;
|
|
12
|
+
specFile: string;
|
|
13
|
+
evidence: string;
|
|
14
|
+
tier: string;
|
|
15
|
+
};
|
|
16
|
+
export declare const OPS: TwinOp[];
|
|
17
|
+
export type TwinRequest = {
|
|
18
|
+
method: string;
|
|
19
|
+
path: string;
|
|
20
|
+
body?: string;
|
|
21
|
+
};
|
|
22
|
+
export type TwinResponse = {
|
|
23
|
+
status: number;
|
|
24
|
+
body: unknown;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
export type OpHandler = (ctx: {
|
|
28
|
+
op: TwinOp;
|
|
29
|
+
params: Record<string, string>;
|
|
30
|
+
query: URLSearchParams;
|
|
31
|
+
body: unknown;
|
|
32
|
+
root?: string;
|
|
33
|
+
}) => TwinResponse | Promise<TwinResponse>;
|
|
34
|
+
/** Match one request against the ratified surface. PURE LOOKUP — dispatch belongs to the pack. */
|
|
35
|
+
export declare function matchOp(req: TwinRequest): {
|
|
36
|
+
op: TwinOp;
|
|
37
|
+
params: Record<string, string>;
|
|
38
|
+
query: URLSearchParams;
|
|
39
|
+
} | undefined;
|
|
40
|
+
/** First required query/body field missing, or undefined (empty for sdk-ratified surfaces —
|
|
41
|
+
* validation truth lives in the pack's semantics, grounded per op). */
|
|
42
|
+
export declare function missingRequired(op: TwinOp, query: URLSearchParams, body: unknown): string | undefined;
|
|
43
|
+
/** SCAFFOLD default error body — the A2 station replaces this with the vendor's REAL envelope. */
|
|
44
|
+
export declare const errorBody: (message: string, field?: string) => {
|
|
45
|
+
field?: string | undefined;
|
|
46
|
+
success: boolean;
|
|
47
|
+
message: string;
|
|
48
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// GENERATED by scripts/sdk-compile.ts from var/line/segment/SURFACE.json — the vendor
|
|
2
|
+
// publishes no machine-readable spec; every op below carries the evidence it was ratified on.
|
|
3
|
+
// Data only; regenerate, never hand-edit.
|
|
4
|
+
export const OPS = [
|
|
5
|
+
{
|
|
6
|
+
"id": "alias",
|
|
7
|
+
"area": "alias",
|
|
8
|
+
"method": "POST",
|
|
9
|
+
"path": "/v1/alias",
|
|
10
|
+
"summary": "associate one identity with another: a `previousId` (userId or anonymousId) is aliased onto a new `userId`",
|
|
11
|
+
"pathParams": [],
|
|
12
|
+
"requiredQuery": [],
|
|
13
|
+
"requiredBody": [],
|
|
14
|
+
"successStatus": 200,
|
|
15
|
+
"responseSkeleton": null,
|
|
16
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
17
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/http-api/index.md '## Alias' declares `POST https://api.segment.io/v1/alias` with a worked body {previousId, userId, timestamp, writeKey} and a field table naming previousId. Absent from @segment/analytics-node@2's wire surface (batched as type:'alias'); the SDK's AliasParams (src/app/types/params.ts) requires both userId and previousId.",
|
|
18
|
+
"tier": "common"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "batch",
|
|
22
|
+
"area": "batch",
|
|
23
|
+
"method": "POST",
|
|
24
|
+
"path": "/v1/batch",
|
|
25
|
+
"summary": "the SERVER-side batched ingestion envelope every official server SDK sends: {batch:[<message>...], writeKey, sentAt}, each message discriminated by its own `type`",
|
|
26
|
+
"pathParams": [],
|
|
27
|
+
"requiredQuery": [],
|
|
28
|
+
"requiredBody": [],
|
|
29
|
+
"successStatus": 200,
|
|
30
|
+
"responseSkeleton": null,
|
|
31
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
32
|
+
"evidence": "SDK WIRE LITERAL. @segment/analytics-node@2.3.0 src/plugins/segmentio/publisher.ts:73-76 builds the one and only URL this SDK ever posts to: tryCreateFormattedUrl(host ?? 'https://api.segment.io', path ?? '/v1/batch'); the body at :243-247 is JSON.stringify({batch: events, writeKey: this._writeKey, sentAt: new Date()}). It is the SOLE real path literal in var/line/segment/dossier/wire.json pathLiterals (the other, '/bar', is a JSDoc example). Corroborated in a second first-party SDK (analytics-python segment/analytics/request.py: url = remove_trailing_slash(host or 'https://api.segment.io') + '/v1/batch') and in the vendor's own reference, github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/http-api/index.md '## Batch'.",
|
|
33
|
+
"tier": "core"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "browser_track",
|
|
37
|
+
"area": "track",
|
|
38
|
+
"method": "POST",
|
|
39
|
+
"path": "/v1/t",
|
|
40
|
+
"summary": "analytics.js's short-form tracking route on the api.segment.io/v1 base — the path a browser proxy must forward",
|
|
41
|
+
"pathParams": [],
|
|
42
|
+
"requiredQuery": [],
|
|
43
|
+
"requiredBody": [],
|
|
44
|
+
"successStatus": 200,
|
|
45
|
+
"responseSkeleton": null,
|
|
46
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
47
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/website/javascript/custom-proxy.md:123-125 documents the Analytics.js `integrations['Segment.io'].apiHost` proxy as: '// POST https://MY-CUSTOM-API-PROXY.com/v1/t --> proxies to // https://api.segment.io/v1/t'. Only the `/v1/t` short form appears on a first-party page; the sibling short routes analytics.js may use for the other call types are NOT documented anywhere in segment-docs and are deliberately not invented here. No literal in @segment/analytics-node@2 (a server SDK; this is the browser route).",
|
|
48
|
+
"tier": "niche"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "group",
|
|
52
|
+
"area": "group",
|
|
53
|
+
"method": "POST",
|
|
54
|
+
"path": "/v1/group",
|
|
55
|
+
"summary": "associate an identified user with a group (company/account/team) and record group traits",
|
|
56
|
+
"pathParams": [],
|
|
57
|
+
"requiredQuery": [],
|
|
58
|
+
"requiredBody": [],
|
|
59
|
+
"successStatus": 200,
|
|
60
|
+
"responseSkeleton": null,
|
|
61
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
62
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/http-api/index.md '## Group' declares `POST https://api.segment.io/v1/group` with a worked body {userId, groupId, traits, timestamp, writeKey} and a field table naming groupId and group traits. Absent from @segment/analytics-node@2's wire surface (batched as type:'group').",
|
|
63
|
+
"tier": "core"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "identify",
|
|
67
|
+
"area": "identify",
|
|
68
|
+
"method": "POST",
|
|
69
|
+
"path": "/v1/identify",
|
|
70
|
+
"summary": "tie a user to their actions and record `traits` about them",
|
|
71
|
+
"pathParams": [],
|
|
72
|
+
"requiredQuery": [],
|
|
73
|
+
"requiredBody": [],
|
|
74
|
+
"successStatus": 200,
|
|
75
|
+
"responseSkeleton": null,
|
|
76
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
77
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/http-api/index.md '## Identify' declares `POST https://api.segment.io/v1/identify` with a worked body {userId, traits, context, timestamp, writeKey} and a field table (anonymousId, context, integrations, timestamp, identify traits, userId); the '## Selecting Destinations' section posts an integrations-bearing identify to the same URL. Absent from @segment/analytics-node@2's wire surface (that SDK sends type:'identify' messages inside /v1/batch).",
|
|
78
|
+
"tier": "core"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "mobile_batch",
|
|
82
|
+
"area": "batch",
|
|
83
|
+
"method": "POST",
|
|
84
|
+
"path": "/v1/b",
|
|
85
|
+
"summary": "the MOBILE batch endpoint — a distinct route from /v1/batch, which the vendor reserves for server-side sending",
|
|
86
|
+
"pathParams": [],
|
|
87
|
+
"requiredQuery": [],
|
|
88
|
+
"requiredBody": [],
|
|
89
|
+
"successStatus": 200,
|
|
90
|
+
"responseSkeleton": null,
|
|
91
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
92
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/mobile/apple/index.md:163 and src/connections/sources/catalog/libraries/mobile/react-native/index.md:781: 'you must forward the batched events to https://api.segment.io/v1/b. The https://api.segment.io/v1/batch endpoint is reserved for events arriving from server-side sending, and proxying to that endpoint for your mobile events may result in unexpected behavior.' Also react-native/index.md:91, the `proxy` option: 'a batch url to post to instead of https://api.segment.io/v1/b'. No literal in @segment/analytics-node@2 — this is the Swift/Kotlin/React-Native route, and no mobile SDK is pinned in this dossier.",
|
|
93
|
+
"tier": "common"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "oauth_token",
|
|
97
|
+
"area": "oauth",
|
|
98
|
+
"method": "POST",
|
|
99
|
+
"path": "/token",
|
|
100
|
+
"summary": "OAuth 2.0 client-credentials token exchange on the regional authorization server: a signed RS256 JWT client assertion is traded for a short-lived access token",
|
|
101
|
+
"pathParams": [],
|
|
102
|
+
"requiredQuery": [],
|
|
103
|
+
"requiredBody": [],
|
|
104
|
+
"successStatus": 200,
|
|
105
|
+
"responseSkeleton": null,
|
|
106
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
107
|
+
"evidence": "SDK WIRE LITERAL (interpolated, which is why wire.json's plain-string grep missed it). @segment/analytics-node@2.3.0 src/lib/token-manager.ts requestAccessToken(): `const accessTokenEndpoint = ${this.authServer}/token`, POSTed as application/x-www-form-urlencoded with grant_type=client_credentials & client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer & client_assertion=<signed JWT> & scope; authServer defaults to 'https://oauth2.segment.io' (same file's constructor) and the EU value 'https://oauth2.eu1.segmentapis.com' is documented on src/lib/types.ts OAuthSettings.authServer. Corroborated by github.com/segmentio/segment-docs src/connections/oauth.md '## Obtain the access token', which names both regional authorization servers and the same four form parameters.",
|
|
108
|
+
"tier": "niche"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"id": "page",
|
|
112
|
+
"area": "page",
|
|
113
|
+
"method": "POST",
|
|
114
|
+
"path": "/v1/page",
|
|
115
|
+
"summary": "record a web page view, with optional category, name and properties",
|
|
116
|
+
"pathParams": [],
|
|
117
|
+
"requiredQuery": [],
|
|
118
|
+
"requiredBody": [],
|
|
119
|
+
"successStatus": 200,
|
|
120
|
+
"responseSkeleton": null,
|
|
121
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
122
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/http-api/index.md '## Page' declares `POST https://api.segment.io/v1/page` with a worked body {userId, name, timestamp, writeKey} and a field table (anonymousId, context, integrations, page name, page properties, timestamp, userId). Absent from @segment/analytics-node@2's wire surface (batched as type:'page').",
|
|
123
|
+
"tier": "core"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "pixel_alias",
|
|
127
|
+
"area": "pixel",
|
|
128
|
+
"method": "GET",
|
|
129
|
+
"path": "/v1/pixel/alias",
|
|
130
|
+
"summary": "Tracking Pixel API: an alias call carried in the query string, answering 200 with a 1x1 transparent GIF",
|
|
131
|
+
"pathParams": [],
|
|
132
|
+
"requiredQuery": [],
|
|
133
|
+
"requiredBody": [],
|
|
134
|
+
"successStatus": 200,
|
|
135
|
+
"responseSkeleton": null,
|
|
136
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
137
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/pixel-tracking-api/index.md '#### Pixel Routes' enumerates exactly six routes as a literal block — /v1/pixel/identify, /v1/pixel/group, /v1/pixel/alias, /v1/pixel/page, /v1/pixel/screen, /v1/pixel/track — under the signature `https://api.segment.io/v1/pixel/<METHOD ENDPOINT>?data=<base64-ENCODED-JSON>`, with 'Each endpoint *always* responds with a 200 <empty-gif>, even if an error occurs'. Not in any SDK.",
|
|
138
|
+
"tier": "niche"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"id": "pixel_group",
|
|
142
|
+
"area": "pixel",
|
|
143
|
+
"method": "GET",
|
|
144
|
+
"path": "/v1/pixel/group",
|
|
145
|
+
"summary": "Tracking Pixel API: a group call carried in the query string, answering 200 with a 1x1 transparent GIF",
|
|
146
|
+
"pathParams": [],
|
|
147
|
+
"requiredQuery": [],
|
|
148
|
+
"requiredBody": [],
|
|
149
|
+
"successStatus": 200,
|
|
150
|
+
"responseSkeleton": null,
|
|
151
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
152
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/pixel-tracking-api/index.md '#### Pixel Routes' enumerates exactly six routes as a literal block — /v1/pixel/identify, /v1/pixel/group, /v1/pixel/alias, /v1/pixel/page, /v1/pixel/screen, /v1/pixel/track — under the signature `https://api.segment.io/v1/pixel/<METHOD ENDPOINT>?data=<base64-ENCODED-JSON>`, with 'Each endpoint *always* responds with a 200 <empty-gif>, even if an error occurs'. Not in any SDK.",
|
|
153
|
+
"tier": "niche"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"id": "pixel_identify",
|
|
157
|
+
"area": "pixel",
|
|
158
|
+
"method": "GET",
|
|
159
|
+
"path": "/v1/pixel/identify",
|
|
160
|
+
"summary": "Tracking Pixel API: an identify call carried in the query string, answering 200 with a 1x1 transparent GIF",
|
|
161
|
+
"pathParams": [],
|
|
162
|
+
"requiredQuery": [],
|
|
163
|
+
"requiredBody": [],
|
|
164
|
+
"successStatus": 200,
|
|
165
|
+
"responseSkeleton": null,
|
|
166
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
167
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/pixel-tracking-api/index.md '#### Pixel Routes' enumerates exactly six routes as a literal block — /v1/pixel/identify, /v1/pixel/group, /v1/pixel/alias, /v1/pixel/page, /v1/pixel/screen, /v1/pixel/track — under the signature `https://api.segment.io/v1/pixel/<METHOD ENDPOINT>?data=<base64-ENCODED-JSON>`, with 'Each endpoint *always* responds with a 200 <empty-gif>, even if an error occurs'. Not in any SDK.",
|
|
168
|
+
"tier": "niche"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"id": "pixel_page",
|
|
172
|
+
"area": "pixel",
|
|
173
|
+
"method": "GET",
|
|
174
|
+
"path": "/v1/pixel/page",
|
|
175
|
+
"summary": "Tracking Pixel API: a page call carried in the query string, answering 200 with a 1x1 transparent GIF",
|
|
176
|
+
"pathParams": [],
|
|
177
|
+
"requiredQuery": [],
|
|
178
|
+
"requiredBody": [],
|
|
179
|
+
"successStatus": 200,
|
|
180
|
+
"responseSkeleton": null,
|
|
181
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
182
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/pixel-tracking-api/index.md '#### Pixel Routes' enumerates exactly six routes as a literal block — /v1/pixel/identify, /v1/pixel/group, /v1/pixel/alias, /v1/pixel/page, /v1/pixel/screen, /v1/pixel/track — under the signature `https://api.segment.io/v1/pixel/<METHOD ENDPOINT>?data=<base64-ENCODED-JSON>`, with 'Each endpoint *always* responds with a 200 <empty-gif>, even if an error occurs'. Not in any SDK.",
|
|
183
|
+
"tier": "niche"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"id": "pixel_screen",
|
|
187
|
+
"area": "pixel",
|
|
188
|
+
"method": "GET",
|
|
189
|
+
"path": "/v1/pixel/screen",
|
|
190
|
+
"summary": "Tracking Pixel API: a screen call carried in the query string, answering 200 with a 1x1 transparent GIF",
|
|
191
|
+
"pathParams": [],
|
|
192
|
+
"requiredQuery": [],
|
|
193
|
+
"requiredBody": [],
|
|
194
|
+
"successStatus": 200,
|
|
195
|
+
"responseSkeleton": null,
|
|
196
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
197
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/pixel-tracking-api/index.md '#### Pixel Routes' enumerates exactly six routes as a literal block — /v1/pixel/identify, /v1/pixel/group, /v1/pixel/alias, /v1/pixel/page, /v1/pixel/screen, /v1/pixel/track — under the signature `https://api.segment.io/v1/pixel/<METHOD ENDPOINT>?data=<base64-ENCODED-JSON>`, with 'Each endpoint *always* responds with a 200 <empty-gif>, even if an error occurs'. Not in any SDK.",
|
|
198
|
+
"tier": "niche"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"id": "pixel_track",
|
|
202
|
+
"area": "pixel",
|
|
203
|
+
"method": "GET",
|
|
204
|
+
"path": "/v1/pixel/track",
|
|
205
|
+
"summary": "Tracking Pixel API: a track call carried in the query string (base64 `?data=` or plain params), answering 200 with a 1x1 transparent GIF",
|
|
206
|
+
"pathParams": [],
|
|
207
|
+
"requiredQuery": [],
|
|
208
|
+
"requiredBody": [],
|
|
209
|
+
"successStatus": 200,
|
|
210
|
+
"responseSkeleton": null,
|
|
211
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
212
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/pixel-tracking-api/index.md gives the signature `https://api.segment.io/v1/pixel/<METHOD ENDPOINT>?data=<base64-ENCODED-JSON>`, lists /v1/pixel/track among its six '#### Pixel Routes', states 'Each endpoint *always* responds with a 200 <empty-gif>, even if an error occurs', and shows both the base64 img-tag form (:67) and the unencoded query-param form (:61). Corroborated at src/connections/sources/custom-domain.md:79 and src/guides/how-to-guides/cross-channel-tracking.md:77. Not in any SDK: this route exists for email/ad contexts where JavaScript and POST are impossible.",
|
|
213
|
+
"tier": "niche"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"id": "screen",
|
|
217
|
+
"area": "screen",
|
|
218
|
+
"method": "POST",
|
|
219
|
+
"path": "/v1/screen",
|
|
220
|
+
"summary": "record a mobile app screen view, with optional category, name and properties",
|
|
221
|
+
"pathParams": [],
|
|
222
|
+
"requiredQuery": [],
|
|
223
|
+
"requiredBody": [],
|
|
224
|
+
"successStatus": 200,
|
|
225
|
+
"responseSkeleton": null,
|
|
226
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
227
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/http-api/index.md '## Screen' declares `POST https://api.segment.io/v1/screen` with a worked body {userId, name, timestamp, writeKey}. @segment/analytics-node@2 exposes a .screen() method (src/app/analytics-node.ts) and its SegmentEventType union includes 'screen' (src/app/types/segment-event.ts), but the message still travels inside /v1/batch — no per-type path literal ships.",
|
|
228
|
+
"tier": "common"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"id": "track",
|
|
232
|
+
"area": "track",
|
|
233
|
+
"method": "POST",
|
|
234
|
+
"path": "/v1/track",
|
|
235
|
+
"summary": "record an action a user performed: required `event` name plus optional `properties`",
|
|
236
|
+
"pathParams": [],
|
|
237
|
+
"requiredQuery": [],
|
|
238
|
+
"requiredBody": [],
|
|
239
|
+
"successStatus": 200,
|
|
240
|
+
"responseSkeleton": null,
|
|
241
|
+
"specFile": "SURFACE.json (sdk/docs-ratified, no vendor spec exists)",
|
|
242
|
+
"evidence": "DOCS ONLY, NOT VERIFIED OFFLINE. github.com/segmentio/segment-docs src/connections/sources/catalog/libraries/server/http-api/index.md '## Track' declares `POST https://api.segment.io/v1/track` with a worked JSON body {userId, event, properties, context, timestamp, writeKey}; the same page's '### writeKey authentication' and '#### OAuth' curl examples both post to that exact URL. NO path literal for it exists in @segment/analytics-node@2 — that SDK batches every call type onto /v1/batch — so this entry is grounded in the vendor's reference page, not in shipped SDK bytes.",
|
|
243
|
+
"tier": "core"
|
|
244
|
+
}
|
|
245
|
+
];
|
|
246
|
+
const compiled = OPS.map((op) => ({ op, re: new RegExp('^' + op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\{([^}]+)\}/g, '(?<$1>[^/]+)') + '$') }));
|
|
247
|
+
/** Match one request against the ratified surface. PURE LOOKUP — dispatch belongs to the pack. */
|
|
248
|
+
export function matchOp(req) {
|
|
249
|
+
const url = new URL(req.path, 'http://twin.local');
|
|
250
|
+
const match = compiled.find((c) => c.op.method === req.method.toUpperCase() && c.re.test(url.pathname));
|
|
251
|
+
if (!match)
|
|
252
|
+
return undefined;
|
|
253
|
+
return { op: match.op, params: { ...(match.re.exec(url.pathname)?.groups ?? {}) }, query: url.searchParams };
|
|
254
|
+
}
|
|
255
|
+
/** First required query/body field missing, or undefined (empty for sdk-ratified surfaces —
|
|
256
|
+
* validation truth lives in the pack's semantics, grounded per op). */
|
|
257
|
+
export function missingRequired(op, query, body) {
|
|
258
|
+
for (const q of op.requiredQuery)
|
|
259
|
+
if (!query.has(q))
|
|
260
|
+
return q;
|
|
261
|
+
for (const f of op.requiredBody)
|
|
262
|
+
if (!body || typeof body !== 'object' || body[f] === undefined)
|
|
263
|
+
return f;
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
/** SCAFFOLD default error body — the A2 station replaces this with the vendor's REAL envelope. */
|
|
267
|
+
export const errorBody = (message, field) => ({ success: false, message, ...(field ? { field } : {}) });
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type OpHandler, type TwinResponse } from './segment-surface.gen.js';
|
|
2
|
+
export type SegmentTwinRequest = {
|
|
3
|
+
method: string;
|
|
4
|
+
path: string;
|
|
5
|
+
body?: string;
|
|
6
|
+
root?: string;
|
|
7
|
+
/** Captured, deliberately NOT enforced: the vendor says a content-type of 'application/json'
|
|
8
|
+
* "must be set" but never states what it does when one is absent or wrong, so inventing a
|
|
9
|
+
* refusal here would be surface Segment does not have. Filed as
|
|
10
|
+
* segment.api.limits.content_type_required. */
|
|
11
|
+
contentType?: string;
|
|
12
|
+
/** The raw Authorization header, when the caller sent one (Basic or Bearer). */
|
|
13
|
+
authorization?: string;
|
|
14
|
+
/** The WORLD instant this request is served at. Defaults to the world clock (R9). */
|
|
15
|
+
occurredAt?: string;
|
|
16
|
+
};
|
|
17
|
+
type Row = Record<string, unknown> & {
|
|
18
|
+
type?: string;
|
|
19
|
+
id?: string;
|
|
20
|
+
};
|
|
21
|
+
/** Every accepted message, oldest-first by ingestion ordinal. One row per Segment `messageId`. */
|
|
22
|
+
export declare function events(root?: string): Row[];
|
|
23
|
+
/** Users as identify (and alias) have folded them: merged traits + the ids they answer to. */
|
|
24
|
+
export declare function identities(root?: string): Row[];
|
|
25
|
+
/** Groups as `group` calls have folded them: merged group traits. */
|
|
26
|
+
export declare function groups(root?: string): Row[];
|
|
27
|
+
/** Messages the vendor ACCEPTS (200) and then drops. Twin-local observability, not vendor surface. */
|
|
28
|
+
export declare function dropped(root?: string): Row[];
|
|
29
|
+
/** Segment's error body. The SHAPE is grounded — analytics-python reads `payload["code"]` and
|
|
30
|
+
* `payload["message"]` off any non-200 (segment/analytics/request.py) — but the vendor
|
|
31
|
+
* publishes no vocabulary of `code` VALUES. The single documented literal is
|
|
32
|
+
* `no_user_anon_id` (http-api/index.md '## Errors'); every other code below is descriptive and
|
|
33
|
+
* is NOT claimed to be the vendor's own string. The gap is filed as
|
|
34
|
+
* segment.api.errors.code_vocabulary and no capability asserts a code the vendor never printed. */
|
|
35
|
+
export declare const segmentError: (status: number, code: string, message: string) => TwinResponse;
|
|
36
|
+
/** Documented drop reasons. Only `no_user_anon_id` is a vendor literal (see above). */
|
|
37
|
+
export type DropReason = 'no_user_anon_id' | 'track_missing_event' | 'group_missing_group_id' | 'alias_missing_previous_id' | 'unknown_message_type' | 'event_exceeds_32kb' | 'batch_exceeds_2500_events';
|
|
38
|
+
export declare const SEGMENT_MAX_REQUEST_BYTES: number;
|
|
39
|
+
export declare const SEGMENT_MAX_BATCH_BYTES: number;
|
|
40
|
+
export declare const SEGMENT_MAX_BATCH_EVENTS = 2500;
|
|
41
|
+
export type ResolvedAuth = {
|
|
42
|
+
writeKey: string | null;
|
|
43
|
+
scheme: 'body' | 'basic' | 'oauth' | 'none';
|
|
44
|
+
bearer: string | null;
|
|
45
|
+
};
|
|
46
|
+
/** Resolve the write key from whichever of the three documented schemes the caller used.
|
|
47
|
+
* The BODY wins when it carries a writeKey, because the docs' OAuth example keeps the writeKey
|
|
48
|
+
* in the payload while the header carries a different credential entirely; Basic is consulted
|
|
49
|
+
* only when the body has none, which is exactly the analytics-node v1 shape. */
|
|
50
|
+
export declare function resolveWriteKey(authorization: string | undefined, body: unknown): ResolvedAuth;
|
|
51
|
+
export type SegmentMessage = Record<string, unknown>;
|
|
52
|
+
export type SegmentEnvelope = {
|
|
53
|
+
messages: SegmentMessage[];
|
|
54
|
+
/** Batch-level context/integrations, merged into each message (see MERGE below). */
|
|
55
|
+
context: Record<string, unknown> | undefined;
|
|
56
|
+
integrations: Record<string, unknown> | undefined;
|
|
57
|
+
writeKey: string | null;
|
|
58
|
+
scheme: ResolvedAuth['scheme'];
|
|
59
|
+
sentAt: string | null;
|
|
60
|
+
/** true when the payload arrived through /v1/batch (a list) rather than a direct route. */
|
|
61
|
+
batched: boolean;
|
|
62
|
+
};
|
|
63
|
+
/** The five message types this twin models, plus the one it ratifies and refuses BY NAME.
|
|
64
|
+
* The union is the SDK's own: `type SegmentEventType = 'track' | 'page' | 'identify' | 'alias'
|
|
65
|
+
* | 'screen'` (src/app/types/segment-event.ts) widened with 'group', which the SDK's
|
|
66
|
+
* `Analytics.group()` produces through CoreEventFactory. */
|
|
67
|
+
export declare const MODELED_TYPES: readonly ["track", "identify", "page", "group", "alias"];
|
|
68
|
+
export type ModeledType = (typeof MODELED_TYPES)[number];
|
|
69
|
+
/** Ratified in SURFACE.json, deliberately not modeled: it earns the loud gap by name. */
|
|
70
|
+
export declare const UNMODELED_TYPES: readonly ["screen"];
|
|
71
|
+
/** THE per-message dispatcher. Every route funnels through it, so `/v1/batch`, `/v1/track` and
|
|
72
|
+
* their siblings can never disagree about what a message means.
|
|
73
|
+
*
|
|
74
|
+
* Two refusal modes, and the difference is deliberate:
|
|
75
|
+
* • a DOCUMENTED rejection (no identifier, Track without a name, an over-cap or oversize
|
|
76
|
+
* batch member) is ACCEPTED-AND-DROPPED — 200, nothing folded into `event` — because that is
|
|
77
|
+
* what Segment does: "Segment returns a 200 response for all API requests except errors
|
|
78
|
+
* caused by large payloads and JSON errors", and its list of silently-rejected events names
|
|
79
|
+
* exactly these cases;
|
|
80
|
+
* • a message whose `type` this twin has RATIFIED but not MODELED (today: `screen`) fails
|
|
81
|
+
* LOUDLY by name and the WHOLE payload is refused with nothing stored. That is a deliberate
|
|
82
|
+
* deviation from the vendor's 200 (ruled:
|
|
83
|
+
* denominator:200-for-nearly-everything-so-the-twin-gap-is-a-deliberate-deviation): a silent
|
|
84
|
+
* 200 storing nothing is indistinguishable from success, which is the one thing a twin may
|
|
85
|
+
* never do. Validation of every message therefore precedes ANY fold. */
|
|
86
|
+
export declare function ingest(env: SegmentEnvelope, endpoint: string, root: string | undefined, at: string): Promise<TwinResponse>;
|
|
87
|
+
/** The ctx a segment SEMANTICS handler gets: the generated one plus the WORLD instant. The
|
|
88
|
+
* generated surface is data + pure helpers and never carries a clock, so the instant is threaded
|
|
89
|
+
* here — `receivedAt` and every minted messageId derive from it (R9). */
|
|
90
|
+
type SegmentOpHandler = (ctx: Parameters<OpHandler>[0] & {
|
|
91
|
+
occurredAt: string;
|
|
92
|
+
}) => ReturnType<OpHandler>;
|
|
93
|
+
export declare const SEMANTICS: Record<string, SegmentOpHandler>;
|
|
94
|
+
/** Decode a request body into the shared envelope. `/v1/batch` carries `{batch: [...]}`; a
|
|
95
|
+
* direct route carries ONE message at the top level, with the writeKey alongside it. */
|
|
96
|
+
export declare function decodeEnvelope(parsed: unknown, auth: ResolvedAuth, isBatchRoute: boolean): SegmentEnvelope | undefined;
|
|
97
|
+
export declare function handleSegmentTwinRequest(req: SegmentTwinRequest): Promise<TwinResponse>;
|
|
98
|
+
export {};
|