@volter/twin-standard 1.0.29 → 1.0.31
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/dist/src/archive-source.d.ts +13 -0
- package/dist/src/archive-source.js +176 -0
- package/dist/src/check-sources.d.ts +19 -2
- package/dist/src/check-sources.js +95 -37
- package/dist/src/cli.js +52 -9
- package/dist/src/clients/ai-gateway.d.ts +1 -0
- package/dist/src/clients/ai-gateway.js +3 -0
- package/dist/src/clients/anthropic-messages.d.ts +10 -0
- package/dist/src/clients/anthropic-messages.js +52 -0
- package/dist/src/clients/anthropic.d.ts +1 -0
- package/dist/src/clients/anthropic.js +3 -0
- package/dist/src/clients/aws.d.ts +2 -0
- package/dist/src/clients/aws.js +137 -0
- package/dist/src/clients/cerebras.d.ts +1 -0
- package/dist/src/clients/cerebras.js +3 -0
- package/dist/src/clients/clerk.d.ts +2 -0
- package/dist/src/clients/clerk.js +99 -0
- package/dist/src/clients/cloudflare.d.ts +2 -0
- package/dist/src/clients/cloudflare.js +86 -0
- package/dist/src/clients/elevenlabs.d.ts +2 -0
- package/dist/src/clients/elevenlabs.js +39 -0
- package/dist/src/clients/gemini.d.ts +2 -0
- package/dist/src/clients/gemini.js +92 -0
- package/dist/src/clients/github.d.ts +2 -0
- package/dist/src/clients/github.js +71 -0
- package/dist/src/clients/groq.d.ts +1 -0
- package/dist/src/clients/groq.js +3 -0
- package/dist/src/clients/harness.d.ts +36 -0
- package/dist/src/clients/harness.js +74 -0
- package/dist/src/clients/merge.d.ts +1 -0
- package/dist/src/clients/merge.js +8 -0
- package/dist/src/clients/openai-compatible.d.ts +9 -0
- package/dist/src/clients/openai-compatible.js +51 -0
- package/dist/src/clients/openai.d.ts +2 -0
- package/dist/src/clients/openai.js +65 -0
- package/dist/src/clients/openrouter.d.ts +1 -0
- package/dist/src/clients/openrouter.js +3 -0
- package/dist/src/clients/perplexity.d.ts +1 -0
- package/dist/src/clients/perplexity.js +3 -0
- package/dist/src/clients/planetscale.d.ts +2 -0
- package/dist/src/clients/planetscale.js +109 -0
- package/dist/src/clients/posthog.d.ts +2 -0
- package/dist/src/clients/posthog.js +145 -0
- package/dist/src/clients/run.d.ts +1 -0
- package/dist/src/clients/run.js +25 -0
- package/dist/src/clients/sentry.d.ts +2 -0
- package/dist/src/clients/sentry.js +167 -0
- package/dist/src/clients/slack.d.ts +2 -0
- package/dist/src/clients/slack.js +107 -0
- package/dist/src/clients/stripe.d.ts +2 -0
- package/dist/src/clients/stripe.js +161 -0
- package/dist/src/clients/tinybird.d.ts +2 -0
- package/dist/src/clients/tinybird.js +71 -0
- package/dist/src/clients/tunnel.d.ts +2 -0
- package/dist/src/clients/tunnel.js +99 -0
- package/dist/src/clients/upstash.d.ts +2 -0
- package/dist/src/clients/upstash.js +143 -0
- package/dist/src/clients/volteridentity.d.ts +2 -0
- package/dist/src/clients/volteridentity.js +128 -0
- package/dist/src/clients/xai.d.ts +2 -0
- package/dist/src/clients/xai.js +23 -0
- package/dist/src/conformance.d.ts +33 -0
- package/dist/src/conformance.js +520 -0
- package/dist/src/derive.js +113 -22
- package/dist/src/gate.js +24 -17
- package/dist/src/index.d.ts +5 -3
- package/dist/src/index.js +5 -3
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/p3-rules.js +0 -2
- package/dist/src/pack-files.d.ts +2 -0
- package/dist/src/pack-files.js +4 -0
- package/dist/src/protocol-3.d.ts +1 -0
- package/dist/src/protocol-3.js +83 -24
- package/dist/src/published.d.ts +6 -0
- package/dist/src/published.js +65 -11
- package/dist/src/spec-documents.js +2 -1
- package/dist/src/spec-ir-client.d.ts +5 -1
- package/dist/src/spec-ir-client.js +12 -2
- package/dist/src/spec-ir.d.ts +7 -2
- package/dist/src/spec-ir.js +42 -10
- package/dist/src/verdict.d.ts +14 -0
- package/dist/src/verdict.js +30 -0
- package/dist/src/walk/behavior-journey.d.ts +47 -0
- package/dist/src/walk/behavior-journey.js +1138 -0
- package/dist/src/walk/git-client.d.ts +26 -0
- package/dist/src/walk/git-client.js +139 -0
- package/dist/src/walk/journey-kit.d.ts +445 -0
- package/dist/src/walk/journey-kit.js +743 -0
- package/dist/src/walk/shape.d.ts +54 -0
- package/dist/src/walk/shape.js +210 -0
- package/dist/src/walk/sse-example.d.ts +2 -0
- package/dist/src/walk/sse-example.js +32 -0
- package/dist/src/walk/vendor-fetch.d.ts +20 -0
- package/dist/src/walk/vendor-fetch.js +92 -0
- package/dist/src/walk/walk-database.d.ts +12 -0
- package/dist/src/walk/walk-database.js +96 -0
- package/dist/src/walk/xml-view.d.ts +2 -0
- package/dist/src/walk/xml-view.js +55 -0
- package/package.json +39 -3
- package/src/archive-source.ts +141 -0
- package/src/check-sources.ts +83 -33
- package/src/cli.ts +40 -8
- package/src/clients/ai-gateway.ts +4 -0
- package/src/clients/anthropic-messages.ts +60 -0
- package/src/clients/anthropic.ts +4 -0
- package/src/clients/aws.ts +119 -0
- package/src/clients/cerebras.ts +4 -0
- package/src/clients/clerk.ts +97 -0
- package/src/clients/cloudflare.ts +80 -0
- package/src/clients/elevenlabs.ts +38 -0
- package/src/clients/gemini.ts +89 -0
- package/src/clients/github.ts +67 -0
- package/src/clients/groq.ts +4 -0
- package/src/clients/harness.ts +101 -0
- package/src/clients/merge.ts +9 -0
- package/src/clients/openai-compatible.ts +59 -0
- package/src/clients/openai.ts +66 -0
- package/src/clients/openrouter.ts +4 -0
- package/src/clients/perplexity.ts +4 -0
- package/src/clients/planetscale.ts +98 -0
- package/src/clients/posthog.ts +132 -0
- package/src/clients/run.ts +18 -0
- package/src/clients/sentry.ts +157 -0
- package/src/clients/slack.ts +98 -0
- package/src/clients/stripe.ts +129 -0
- package/src/clients/tinybird.ts +69 -0
- package/src/clients/tunnel.ts +79 -0
- package/src/clients/upstash.ts +137 -0
- package/src/clients/volteridentity.ts +108 -0
- package/src/clients/xai.ts +25 -0
- package/src/conformance.ts +446 -0
- package/src/derive.ts +87 -16
- package/src/gate.ts +22 -15
- package/src/index.ts +5 -3
- package/src/lanes.ts +7 -0
- package/src/p3-rules.ts +0 -2
- package/src/pack-files.ts +4 -0
- package/src/protocol-3.ts +77 -22
- package/src/published.ts +59 -11
- package/src/spec-documents.ts +2 -1
- package/src/spec-ir-client.ts +15 -4
- package/src/spec-ir.ts +43 -5
- package/src/verdict.ts +28 -0
- package/src/walk/behavior-journey.ts +806 -0
- package/src/walk/git-client.ts +138 -0
- package/src/walk/journey-kit.ts +858 -0
- package/src/walk/shape.ts +208 -0
- package/src/walk/sse-example.ts +25 -0
- package/src/walk/vendor-fetch.ts +80 -0
- package/src/walk/walk-database.ts +97 -0
- package/src/walk/xml-view.ts +44 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// Official SDKs at the demanded applications' pinned versions. The native HTTP module
|
|
10
|
+
// takes the harness agent, preserving the SDK's DSN URL, compression, queue and wire.
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { request as httpsRequest } from 'node:https';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
// The pins live in ../../client-sdks with their own lockfile, installed here on first use: some carry advisories with no
|
|
17
|
+
// fix in the line an application pins, so the repository's install (and a release, .github/release-scan.mjs) never does.
|
|
18
|
+
const SDKS = new URL('../../client-sdks/', import.meta.url);
|
|
19
|
+
async function sdkOf(name) {
|
|
20
|
+
if (!existsSync(new URL('node_modules/', SDKS))) {
|
|
21
|
+
const install = spawnSync('bun', ['install', '--frozen-lockfile', '--ignore-scripts'], { cwd: SDKS, stdio: 'inherit' });
|
|
22
|
+
if (install.status !== 0)
|
|
23
|
+
throw new Error('client-sdks: bun install --frozen-lockfile failed');
|
|
24
|
+
}
|
|
25
|
+
return await import(__rewriteRelativeImportExtension(pathToFileURL(createRequire(new URL('package.json', SDKS)).resolve(name)).href));
|
|
26
|
+
}
|
|
27
|
+
const pins = [
|
|
28
|
+
['rallly', '@sentry/node', '10.38.0'],
|
|
29
|
+
['twenty', 'sentry-node-twenty', '10.73.0'],
|
|
30
|
+
['postiz', 'sentry-node-postiz', '10.56.0'],
|
|
31
|
+
['cal-api', 'sentry-node-cal', '9.46.0'],
|
|
32
|
+
['cal-web', 'sentry-node-cal-web', '10.33.0'],
|
|
33
|
+
];
|
|
34
|
+
async function token(c) {
|
|
35
|
+
const answer = await c.fetch('https://' + c.host + '/_twin/auth-tokens', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ organization: c.env.SENTRY_ORG, name: 'SDK read-back', kind: 'personal', scopes: ['org:read', 'project:read', 'event:read'] }) });
|
|
37
|
+
if (!answer.ok)
|
|
38
|
+
throw new Error('read-back token: ' + answer.status);
|
|
39
|
+
return (await answer.json()).token;
|
|
40
|
+
}
|
|
41
|
+
async function read(c, id, auth) {
|
|
42
|
+
const answer = await c.fetch(`https://${c.host}/api/0/projects/${c.env.SENTRY_ORG}/${c.env.SENTRY_PROJECT}/events/${id}/`, { headers: { authorization: 'Bearer ' + auth } });
|
|
43
|
+
if (!answer.ok)
|
|
44
|
+
throw new Error('event read-back: ' + answer.status);
|
|
45
|
+
return await answer.json();
|
|
46
|
+
}
|
|
47
|
+
export const cases = pins.map(([application, name, version]) => ({
|
|
48
|
+
id: application + '-native-exception-flush-compression',
|
|
49
|
+
asks: 'captureException prepares its event ID, stack and scope; flush drains the native compressed envelope and its event reads back',
|
|
50
|
+
source: `https://unpkg.com/@sentry/core@${version}/build/esm/client.js "transport.flush(timeout)"`,
|
|
51
|
+
async run(c) {
|
|
52
|
+
const sdk = await sdkOf(name);
|
|
53
|
+
const requests = [];
|
|
54
|
+
const client = new sdk.NodeClient({ dsn: c.env.SENTRY_DSN, integrations: [], stackParser: sdk.defaultStackParser,
|
|
55
|
+
release: 'sdk-' + application, environment: 'sdk', transport: (options) => sdk.makeNodeTransport({ ...options,
|
|
56
|
+
httpModule: { request(options, callback) { requests.push({ url: String(options.path), encoding: options.headers?.['Content-Encoding'] ?? options.headers?.['content-encoding'] }); return httpsRequest({ ...options, agent: c.agent }, callback); } },
|
|
57
|
+
}) });
|
|
58
|
+
const scope = new sdk.Scope();
|
|
59
|
+
scope.setUser({ id: 'sdk-member' });
|
|
60
|
+
scope.setTag('application', application);
|
|
61
|
+
scope.setExtra('large-context', 'x'.repeat(64 * 1024));
|
|
62
|
+
const id = client.captureException(new Error('SDK ' + application + ' exception'), undefined, scope);
|
|
63
|
+
const drained = await client.flush(10_000);
|
|
64
|
+
const event = await read(c, id, await token(c));
|
|
65
|
+
await client.close(10_000);
|
|
66
|
+
const failures = [];
|
|
67
|
+
if (!drained || requests.length !== 1 || !requests[0].url.includes('/envelope/'))
|
|
68
|
+
failures.push('flush did not drain exactly one exception envelope');
|
|
69
|
+
if (requests[0]?.encoding !== 'gzip')
|
|
70
|
+
failures.push('large Node envelope was not compressed');
|
|
71
|
+
if (event.eventID !== id || event.user?.id !== 'sdk-member' || event.context?.['large-context']?.length !== 64 * 1024)
|
|
72
|
+
failures.push('event ID or scope did not survive the native transport');
|
|
73
|
+
if (!event.entries?.some((e) => e.type === 'exception' && e.data.values[0]?.stacktrace?.frames?.length))
|
|
74
|
+
failures.push('the SDK stack was lost');
|
|
75
|
+
return failures;
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
for (const [name, version] of [['@sentry/browser', '10.38.0'], ['sentry-browser-twenty', '10.59.0'], ['sentry-browser-postiz', '10.56.0']])
|
|
79
|
+
cases.push({
|
|
80
|
+
id: 'browser-' + version + '-dsn-query-auth-and-session-envelope',
|
|
81
|
+
asks: 'the browser fetch transport uses DSN query authentication and flushes event and session envelopes',
|
|
82
|
+
source: `https://unpkg.com/@sentry/core@${version}/build/esm/api.js "sentry_key: publicKey"`,
|
|
83
|
+
async run(c) {
|
|
84
|
+
const sdk = await sdkOf(name);
|
|
85
|
+
const { makeSession } = await sdkOf('@sentry/core');
|
|
86
|
+
const sent = [];
|
|
87
|
+
const forward = (async (url, init) => { const request = new Request(url, init); sent.push(request.clone()); return c.fetch(request); });
|
|
88
|
+
const client = new sdk.BrowserClient({ dsn: c.env.SENTRY_DSN, integrations: [], stackParser: sdk.defaultStackParser,
|
|
89
|
+
transport: (options) => sdk.makeFetchTransport(options, forward) });
|
|
90
|
+
const id = client.captureEvent({ message: 'browser SDK case', level: 'error', platform: 'javascript' });
|
|
91
|
+
client.captureSession(makeSession({ release: 'sdk-browser', environment: 'sdk' }));
|
|
92
|
+
const drained = await client.flush(10_000);
|
|
93
|
+
const event = await read(c, id, await token(c));
|
|
94
|
+
await client.close(10_000);
|
|
95
|
+
return drained && sent.length === 2 && sent.every((r) => new URL(r.url).searchParams.has('sentry_key')) && event.message === 'browser SDK case' ? [] : ['browser queue, DSN query authentication or event/session envelopes failed'];
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
cases.push({
|
|
99
|
+
id: 'twenty-cron-check-in-options-and-id',
|
|
100
|
+
asks: 'captureCheckIn creates an ID for in_progress and reuses it for ok with the monitor schedule and configured margins',
|
|
101
|
+
source: 'https://unpkg.com/@sentry/core@10.73.0/build/esm/server-runtime-client.js "checkin_margin: monitorConfig.checkinMargin"',
|
|
102
|
+
async run(c) {
|
|
103
|
+
const sdk = await sdkOf('sentry-node-twenty');
|
|
104
|
+
const sent = [];
|
|
105
|
+
const client = new sdk.NodeClient({ dsn: c.env.SENTRY_DSN, integrations: [], stackParser: sdk.defaultStackParser,
|
|
106
|
+
transport: (options) => sdk.createTransport(options, async (request) => {
|
|
107
|
+
sent.push(typeof request.body === 'string' ? request.body : new TextDecoder().decode(request.body));
|
|
108
|
+
const answer = await c.fetch(options.url, { method: 'POST', body: request.body });
|
|
109
|
+
return { statusCode: answer.status, headers: {} };
|
|
110
|
+
}) });
|
|
111
|
+
let id = '';
|
|
112
|
+
sdk.withScope((scope) => { scope.setClient(client); id = sdk.captureCheckIn({ monitorSlug: 'sdk-cleanup', status: 'in_progress' }, { schedule: { type: 'crontab', value: '0 3 * * *' }, checkinMargin: 1, maxRuntime: 5 }); sdk.captureCheckIn({ monitorSlug: 'sdk-cleanup', status: 'ok', checkInId: id }); });
|
|
113
|
+
const drained = await client.flush(10_000);
|
|
114
|
+
await client.close(10_000);
|
|
115
|
+
const payloads = sent.map((s) => JSON.parse(s.split('\n')[2]));
|
|
116
|
+
return drained && payloads.length === 2 && payloads[0].check_in_id === id && payloads[1].check_in_id === id && payloads[0].monitor_config.checkin_margin === 1 && payloads[0].monitor_config.max_runtime === 5 && !('duration' in payloads[1]) ? [] : ['check-in ID or monitor options changed'];
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
cases.push({
|
|
120
|
+
id: 'transport-rate-limit-drop-and-no-retry',
|
|
121
|
+
asks: 'core transport drains its queue, drops a rate-limited category and does not retry a 503',
|
|
122
|
+
source: 'https://unpkg.com/@sentry/core@10.38.0/build/esm/transports/base.js "ratelimit_backoff"',
|
|
123
|
+
async run(c) {
|
|
124
|
+
const sdk = await sdkOf('@sentry/core');
|
|
125
|
+
const ds = new URL(c.env.SENTRY_DSN);
|
|
126
|
+
const url = `https://${ds.host}/api${ds.pathname}/envelope/?sentry_key=${ds.username}&sentry_version=7`;
|
|
127
|
+
let attempts = 0;
|
|
128
|
+
const dropped = [];
|
|
129
|
+
const transport = sdk.createTransport({ recordDroppedEvent: (reason) => { dropped.push(reason); } }, async (request) => {
|
|
130
|
+
attempts++;
|
|
131
|
+
if (attempts === 1)
|
|
132
|
+
return { statusCode: 503, headers: {} };
|
|
133
|
+
const response = await c.fetch(url, { method: 'POST', body: request.body });
|
|
134
|
+
return { statusCode: response.status, headers: { 'x-sentry-rate-limits': '60:error:organization' } };
|
|
135
|
+
});
|
|
136
|
+
const envelope = (id) => sdk.createEnvelope({ event_id: id }, [[{ type: 'event' }, { event_id: id, message: 'transport SDK case', platform: 'javascript' }]]);
|
|
137
|
+
await transport.send(envelope('00000000000000000000000000000001'));
|
|
138
|
+
await transport.send(envelope('00000000000000000000000000000002'));
|
|
139
|
+
await transport.send(envelope('00000000000000000000000000000003'));
|
|
140
|
+
const drained = await transport.flush(10_000);
|
|
141
|
+
return drained && attempts === 2 && dropped.includes('ratelimit_backoff') ? [] : ['transport retried 503, failed to drain, or sent a rate-limited event'];
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
cases.push({
|
|
145
|
+
id: 'postiz-feedback-attribution',
|
|
146
|
+
asks: 'captureFeedback preserves the widget message, explicit name/email, associated event and scoped user',
|
|
147
|
+
source: 'https://unpkg.com/@sentry/core@10.56.0/build/esm/feedback.js "associated_event_id: associatedEventId"',
|
|
148
|
+
async run(c) {
|
|
149
|
+
const sdk = await sdkOf('sentry-browser-postiz');
|
|
150
|
+
const client = new sdk.BrowserClient({ dsn: c.env.SENTRY_DSN, integrations: [], stackParser: sdk.defaultStackParser,
|
|
151
|
+
transport: (options) => sdk.createTransport(options, async (request) => {
|
|
152
|
+
const answer = await c.fetch(options.url, { method: 'POST', body: request.body });
|
|
153
|
+
return { statusCode: answer.status, headers: {} };
|
|
154
|
+
}) });
|
|
155
|
+
let id = '';
|
|
156
|
+
sdk.withScope((scope) => {
|
|
157
|
+
scope.setClient(client);
|
|
158
|
+
scope.setUser({ id: 'feedback-author' });
|
|
159
|
+
id = sdk.captureFeedback({ message: 'The scheduling button is hard to find', name: 'Priya', email: 'priya@harbourline.test', associatedEventId: '00000000000000000000000000000002' }, {}, scope);
|
|
160
|
+
});
|
|
161
|
+
const drained = await client.flush(10_000);
|
|
162
|
+
const event = await read(c, id, await token(c));
|
|
163
|
+
await client.close(10_000);
|
|
164
|
+
const feedback = event.contexts?.feedback;
|
|
165
|
+
return drained && event.type === 'feedback' && event.user?.id === 'feedback-author' && feedback?.name === 'Priya' && feedback?.contact_email === 'priya@harbourline.test' && feedback?.associated_event_id === '00000000000000000000000000000002' ? [] : ['feedback attribution or associated event was lost'];
|
|
166
|
+
},
|
|
167
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const MESSAGE = 'https://docs.slack.dev/reference/events/message.channels';
|
|
2
|
+
const UPLOADS = 'https://docs.slack.dev/messaging/working-with-files';
|
|
3
|
+
/** A message event's fields as Slack documents them (its example payload, and the fields a message carries). */
|
|
4
|
+
const MESSAGE_FIELDS = new Set(['type', 'subtype', 'channel', 'user', 'text', 'ts', 'event_ts', 'channel_type', 'thread_ts', 'parent_user_id', 'blocks', 'team', 'client_msg_id', 'bot_id', 'app_id', 'bot_profile', 'attachments', 'files', 'edited', 'username', 'icons', 'upload', 'display_as_bot']);
|
|
5
|
+
/** A workspace, a channel, and an app with Socket Mode subscribed to the channel's messages, installed by the workspace's
|
|
6
|
+
* owner through Slack's own pages and API, as a team sets one up. */
|
|
7
|
+
async function install(c) {
|
|
8
|
+
const person = 'xoxp-UMAYA';
|
|
9
|
+
const call = async (url, body, token) => c.fetch(url, { method: 'POST', redirect: 'manual', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) });
|
|
10
|
+
const json = async (r) => (await r.json());
|
|
11
|
+
await call('https://slack.com/get-started', { email: 'maya@kiln.studio', name: 'Maya Okafor' }, person);
|
|
12
|
+
const channel = (await json(await call('https://slack.com/api/conversations.create', { name: 'studio' }, person))).channel?.id;
|
|
13
|
+
const config = /access token is (xoxe\.xoxp-[A-Za-z0-9-]+)/.exec(await (await call('https://api.slack.com/apps', { team: 'T-twin' }, person)).text())?.[1];
|
|
14
|
+
if (!channel || !config)
|
|
15
|
+
throw new Error(`the workspace or its app configuration token was not made (channel ${channel}, token ${config})`);
|
|
16
|
+
const app = await json(await call('https://slack.com/api/apps.manifest.create', { manifest: {
|
|
17
|
+
display_information: { name: 'Conformance Bot' },
|
|
18
|
+
features: { bot_user: { display_name: 'conformance_bot' } },
|
|
19
|
+
oauth_config: { redirect_urls: ['https://bot.example.com/slack/oauth'], scopes: { bot: ['chat:write', 'channels:history', 'files:write'] } },
|
|
20
|
+
settings: { socket_mode_enabled: true, event_subscriptions: { bot_events: ['message.channels'] } },
|
|
21
|
+
} }, config));
|
|
22
|
+
if (!app.ok)
|
|
23
|
+
throw new Error(`apps.manifest.create: ${JSON.stringify(app).slice(0, 160)}`);
|
|
24
|
+
const allowed = await call('https://slack.com/oauth/v2/authorize', { client_id: app.credentials.client_id, scope: 'chat:write,channels:history,files:write', redirect_uri: 'https://bot.example.com/slack/oauth', state: 'conform', answer: 'allow' }, person);
|
|
25
|
+
const code = new URL(allowed.headers.get('location') ?? 'http://x/').searchParams.get('code');
|
|
26
|
+
if (!code)
|
|
27
|
+
throw new Error(`the install was not allowed (${allowed.status})`);
|
|
28
|
+
const access = await json(await call('https://slack.com/api/oauth.v2.access', { client_id: app.credentials.client_id, client_secret: app.credentials.client_secret, code, redirect_uri: 'https://bot.example.com/slack/oauth' }));
|
|
29
|
+
const appToken = /The token is (xapp-[A-Za-z0-9-]+)/.exec(await (await call(`https://api.slack.com/apps/${app.app_id}/app-level-tokens`, { name: 'conformance', scopes: 'connections:write' }, person)).text())?.[1];
|
|
30
|
+
if (!access.ok || !appToken)
|
|
31
|
+
throw new Error(`the bot or app-level token was not issued (${JSON.stringify(access).slice(0, 120)})`);
|
|
32
|
+
await call('https://slack.com/api/conversations.invite', { channel, users: access.bot_user_id }, person);
|
|
33
|
+
return { channel, botUser: String(access.bot_user_id), botToken: String(access.access_token), appToken, person };
|
|
34
|
+
}
|
|
35
|
+
export const cases = [
|
|
36
|
+
{
|
|
37
|
+
id: 'socket-mode-message',
|
|
38
|
+
asks: "an app's Socket Mode client hears a person's message in its bot's channel as Slack documents the event, with nothing else in it",
|
|
39
|
+
source: `${MESSAGE} "The semantics for this event"`,
|
|
40
|
+
async run(c) {
|
|
41
|
+
const installed = await install(c);
|
|
42
|
+
const { SocketModeClient } = await import('@slack/socket-mode');
|
|
43
|
+
const { WebClient, LogLevel } = await import('@slack/web-api');
|
|
44
|
+
const socket = new SocketModeClient({ appToken: installed.appToken, logLevel: LogLevel.ERROR, clientOptions: { agent: c.agent } });
|
|
45
|
+
const heard = new Promise((resolve) => {
|
|
46
|
+
const timer = setTimeout(() => resolve('no message event in 10 s'), 10_000);
|
|
47
|
+
socket.on('message', async ({ event, ack }) => { await ack(); clearTimeout(timer); resolve(event); });
|
|
48
|
+
});
|
|
49
|
+
const started = await socket.start().then(() => undefined, (e) => e.message);
|
|
50
|
+
if (started)
|
|
51
|
+
return [`the Socket Mode client did not connect: ${started}`];
|
|
52
|
+
const person = new WebClient(installed.person, { agent: c.agent, logLevel: LogLevel.ERROR });
|
|
53
|
+
await person.chat.postMessage({ channel: installed.channel, text: 'The client brief is in.' });
|
|
54
|
+
const event = await heard;
|
|
55
|
+
await socket.disconnect().catch(() => undefined);
|
|
56
|
+
if (typeof event === 'string')
|
|
57
|
+
return [event];
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const k of ['type', 'channel', 'user', 'text', 'ts', 'event_ts', 'channel_type'])
|
|
60
|
+
if (event[k] === undefined)
|
|
61
|
+
out.push(`the event carries no ${k}, which Slack's payload has`);
|
|
62
|
+
for (const k of Object.keys(event))
|
|
63
|
+
if (!MESSAGE_FIELDS.has(k))
|
|
64
|
+
out.push(`the event carries ${k}, which no Slack message event has`);
|
|
65
|
+
if (event.text !== 'The client brief is in.')
|
|
66
|
+
out.push(`the event's text is ${JSON.stringify(event.text)}`);
|
|
67
|
+
return out;
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'files-upload-v2',
|
|
72
|
+
asks: "an app's files.uploadV2 sends a file's bytes to the upload URL Slack answers and completes the upload into a thread, where the file reads back with its size",
|
|
73
|
+
source: `${UPLOADS} "Post the contents of your file to the URL returned by the files.getUploadURLExternal API method"`,
|
|
74
|
+
async run(c) {
|
|
75
|
+
const installed = await install(c);
|
|
76
|
+
const { WebClient, LogLevel } = await import('@slack/web-api');
|
|
77
|
+
const bot = new WebClient(installed.botToken, { agent: c.agent, logLevel: LogLevel.ERROR });
|
|
78
|
+
const root = await bot.chat.postMessage({ channel: installed.channel, text: 'Render finished' });
|
|
79
|
+
if (!root.ts)
|
|
80
|
+
return [`the thread's first message was not posted: ${JSON.stringify(root).slice(0, 160)}`];
|
|
81
|
+
const bytes = Buffer.from('frame,seconds\n1,0.42\n2,0.40\n');
|
|
82
|
+
let uploaded;
|
|
83
|
+
try {
|
|
84
|
+
// as the Chat SDK's Slack adapter calls it: the files in file_uploads, the thread's channel_id and thread_ts
|
|
85
|
+
uploaded = await bot.files.uploadV2({ channel_id: installed.channel, thread_ts: root.ts, file_uploads: [{ file: bytes, filename: 'timings.csv' }] });
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
return [`files.uploadV2 failed: ${e.message}`];
|
|
89
|
+
}
|
|
90
|
+
const id = uploaded.files?.[0]?.files?.[0]?.id;
|
|
91
|
+
if (!uploaded.ok || !id)
|
|
92
|
+
return [`files.uploadV2 answered no file: ${JSON.stringify(uploaded).slice(0, 200)}`];
|
|
93
|
+
const replies = await bot.conversations.replies({ channel: installed.channel, ts: root.ts });
|
|
94
|
+
const shared = (replies.messages ?? []).flatMap((m) => (m.files ?? []).map((f) => ({ f, thread: m.thread_ts }))).find((x) => x.f.id === id);
|
|
95
|
+
const out = [];
|
|
96
|
+
if (!shared)
|
|
97
|
+
out.push(`the thread holds no message carrying file ${id}`);
|
|
98
|
+
else {
|
|
99
|
+
if (shared.thread !== root.ts)
|
|
100
|
+
out.push(`the file's message is in thread ${String(shared.thread)}, not ${root.ts}`);
|
|
101
|
+
if (shared.f.size !== bytes.length)
|
|
102
|
+
out.push(`the file's size is ${String(shared.f.size)}, not the ${bytes.length} bytes sent`);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
];
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Stripe's own client (stripe-node), driving the stripe pack as its applications do (Cal.com, Dub, Postiz, Rallly and
|
|
2
|
+
// Twenty call it): an endpoint registered through the API and the event Stripe sends it verified by constructEvent, as an
|
|
3
|
+
// application's webhook handler verifies it; a customer list read page by page by the client's auto-pagination; a retry
|
|
4
|
+
// sent with its idempotency key; the error a missing object raises; and the customer portal a billing page opens.
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
const SIGNATURE = 'https://docs.stripe.com/webhooks/signature';
|
|
7
|
+
const EVENT = 'https://docs.stripe.com/api/events/object';
|
|
8
|
+
const PAGINATION = 'https://docs.stripe.com/api/pagination/auto';
|
|
9
|
+
const IDEMPOTENCY = 'https://docs.stripe.com/api/idempotent_requests';
|
|
10
|
+
const ERRORS = 'https://docs.stripe.com/api/errors/handling';
|
|
11
|
+
const PORTAL = 'https://docs.stripe.com/api/customer_portal/sessions/object';
|
|
12
|
+
const failed = (what) => (e) => new Error(`${what} failed: ${e.name}: ${e.message}`);
|
|
13
|
+
async function stripe(c) {
|
|
14
|
+
const { default: Stripe } = await import('stripe');
|
|
15
|
+
return new Stripe(c.env.STRIPE_SECRET_KEY ?? '', { httpAgent: c.agent, maxNetworkRetries: 0, timeout: 10_000 });
|
|
16
|
+
}
|
|
17
|
+
export const cases = [
|
|
18
|
+
{
|
|
19
|
+
id: 'webhook-event-verified',
|
|
20
|
+
asks: "the event Stripe sends an endpoint is verified by its secret, and its data.object is the customer as the API answers it",
|
|
21
|
+
source: `${SIGNATURE} "use the Stripe-Signature header and call the constructEvent() function"; ${EVENT} "Object containing the API resource relevant to the event."`,
|
|
22
|
+
async run(c) {
|
|
23
|
+
// the application's endpoint: a server of this process, as an application's own
|
|
24
|
+
const received = [];
|
|
25
|
+
const receiver = createServer((req, res) => {
|
|
26
|
+
let body = '';
|
|
27
|
+
req.on('data', (d) => { body += d; });
|
|
28
|
+
req.on('end', () => { received.push({ body, signature: String(req.headers['stripe-signature'] ?? '') }); res.end('{}'); });
|
|
29
|
+
});
|
|
30
|
+
await new Promise((r) => receiver.listen(0, '127.0.0.1', () => r()));
|
|
31
|
+
const port = receiver.address().port;
|
|
32
|
+
try {
|
|
33
|
+
const client = await stripe(c);
|
|
34
|
+
const endpoint = await client.webhookEndpoints.create({ url: `http://127.0.0.1:${port}/stripe`, enabled_events: ['customer.created'] });
|
|
35
|
+
const customer = await client.customers.create({ email: 'ada@example.com', name: 'Ada Lovelace' });
|
|
36
|
+
for (let i = 0; i < 50 && !received.length; i++)
|
|
37
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
38
|
+
const delivery = received[0];
|
|
39
|
+
if (!delivery)
|
|
40
|
+
return ['no event reached the endpoint in 5 s'];
|
|
41
|
+
let event;
|
|
42
|
+
try {
|
|
43
|
+
event = client.webhooks.constructEvent(delivery.body, delivery.signature, String(endpoint.secret));
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
return [`constructEvent refused the delivery: ${e.message}`];
|
|
47
|
+
}
|
|
48
|
+
const out = [];
|
|
49
|
+
if (event.type !== 'customer.created')
|
|
50
|
+
out.push(`the event is ${event.type}`);
|
|
51
|
+
const object = event.data.object;
|
|
52
|
+
if (object.id !== customer.id)
|
|
53
|
+
out.push(`data.object is ${String(object.id)}, not the customer made (${customer.id})`);
|
|
54
|
+
const answered = await client.customers.retrieve(customer.id);
|
|
55
|
+
for (const k of Object.keys(object))
|
|
56
|
+
if (!(k in answered))
|
|
57
|
+
out.push(`data.object carries ${k}, which the API's customer does not`);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
receiver.close();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'customers-auto-paginated',
|
|
67
|
+
asks: 'a list read with the client\'s auto-pagination, two to a page, holds every customer of the email, newest first, across its pages',
|
|
68
|
+
source: `${PAGINATION} "This feature allows you to easily iterate through large lists of resources without having to manually perform the requests to fetch subsequent pages."`,
|
|
69
|
+
async run(c) {
|
|
70
|
+
try {
|
|
71
|
+
const client = await stripe(c);
|
|
72
|
+
const made = [];
|
|
73
|
+
for (const name of ['Ada Lovelace', 'Grace Hopper', 'Katherine Johnson']) {
|
|
74
|
+
made.push((await client.customers.create({ email: 'team@analytical.example', name }).catch((e) => { throw failed('customers.create')(e); })).id);
|
|
75
|
+
}
|
|
76
|
+
const listed = await client.customers.list({ email: 'team@analytical.example', limit: 2 }).autoPagingToArray({ limit: 10 }).catch((e) => { throw failed('customers.list auto-pagination')(e); });
|
|
77
|
+
const ids = listed.map((x) => x.id);
|
|
78
|
+
const expected = [...made].reverse();
|
|
79
|
+
return ids.join(',') === expected.join(',') ? [] : [`auto-pagination read ${ids.join(', ') || 'nothing'}, not the three customers newest first (${expected.join(', ')})`];
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
return [e.message];
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'idempotent-retry',
|
|
88
|
+
asks: 'a create retried with its idempotency key answers the first create\'s customer, and the key sent with other parameters is refused',
|
|
89
|
+
source: `${IDEMPOTENCY} "Subsequent requests with the same key return the same result"; ${IDEMPOTENCY} "The idempotency layer compares incoming parameters to those of the original request and errors if they’re not the same to prevent accidental misuse."`,
|
|
90
|
+
async run(c) {
|
|
91
|
+
try {
|
|
92
|
+
const client = await stripe(c);
|
|
93
|
+
const key = 'client-case-signup-ada';
|
|
94
|
+
const first = await client.customers.create({ email: 'ada@example.com', name: 'Ada Lovelace' }, { idempotencyKey: key }).catch((e) => { throw failed('customers.create')(e); });
|
|
95
|
+
const retried = await client.customers.create({ email: 'ada@example.com', name: 'Ada Lovelace' }, { idempotencyKey: key }).catch((e) => { throw failed('customers.create, retried')(e); });
|
|
96
|
+
const out = [];
|
|
97
|
+
if (retried.id !== first.id)
|
|
98
|
+
out.push(`the retry made ${retried.id}, not the first create's ${first.id}`);
|
|
99
|
+
const misuse = await client.customers.create({ email: 'grace@example.com', name: 'Grace Hopper' }, { idempotencyKey: key }).then(() => undefined, (e) => e);
|
|
100
|
+
if (!misuse)
|
|
101
|
+
out.push('the key sent with other parameters made a customer');
|
|
102
|
+
else if (misuse.type !== 'StripeIdempotencyError')
|
|
103
|
+
out.push(`the key sent with other parameters raised ${String(misuse.type)}, not StripeIdempotencyError`);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
return [e.message];
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'missing-object-error',
|
|
113
|
+
asks: 'retrieving a customer that does not exist raises the client\'s invalid-request error, 404, code resource_missing',
|
|
114
|
+
source: `${ERRORS} "Our client libraries raise exceptions for many reasons, such as a failed charge, invalid parameters, authentication errors, and network unavailability."`,
|
|
115
|
+
async run(c) {
|
|
116
|
+
try {
|
|
117
|
+
const client = await stripe(c);
|
|
118
|
+
const raised = await client.customers.retrieve('cus_never_made').then(() => undefined, (e) => e);
|
|
119
|
+
if (!raised)
|
|
120
|
+
return ['retrieving a customer that does not exist answered one'];
|
|
121
|
+
const out = [];
|
|
122
|
+
if (raised.type !== 'StripeInvalidRequestError')
|
|
123
|
+
out.push(`it raised ${String(raised.type)}, not StripeInvalidRequestError`);
|
|
124
|
+
if (raised.statusCode !== 404)
|
|
125
|
+
out.push(`its status is ${String(raised.statusCode)}, not 404`);
|
|
126
|
+
if (raised.code !== 'resource_missing')
|
|
127
|
+
out.push(`its code is ${String(raised.code)}, not resource_missing`);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
return [e.message];
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'portal-session-opens',
|
|
137
|
+
asks: 'a portal session made for a customer answers its return_url and a url on billing.stripe.com, and that url opens the customer portal',
|
|
138
|
+
source: `${PORTAL} "The short-lived URL of the session that gives customers access to the customer portal."`,
|
|
139
|
+
async run(c) {
|
|
140
|
+
try {
|
|
141
|
+
const client = await stripe(c);
|
|
142
|
+
const customer = await client.customers.create({ email: 'billing@analytical.example', name: 'Analytical Engines' }).catch((e) => { throw failed('customers.create')(e); });
|
|
143
|
+
const session = await client.billingPortal.sessions.create({ customer: customer.id, return_url: 'https://app.analytical.example/billing' }).catch((e) => { throw failed('billingPortal.sessions.create')(e); });
|
|
144
|
+
const out = [];
|
|
145
|
+
if (session.return_url !== 'https://app.analytical.example/billing')
|
|
146
|
+
out.push(`the session's return_url is ${String(session.return_url)}`);
|
|
147
|
+
if (!session.url.startsWith('https://billing.stripe.com/')) {
|
|
148
|
+
out.push(`the session's url is ${session.url}, not on billing.stripe.com`);
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
const page = await c.fetch(session.url);
|
|
152
|
+
if (page.status !== 200)
|
|
153
|
+
out.push(`the portal page answered ${page.status}`);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
return [e.message];
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const SDK = 'https://unpkg.com/@tinybirdco/sdk@0.0.84/src/api/api.ts';
|
|
2
|
+
async function client(c, fetch = c.fetch) {
|
|
3
|
+
const { createTinybirdApi } = await import('@tinybirdco/sdk/api');
|
|
4
|
+
return createTinybirdApi({ baseUrl: `https://${c.host}`, token: c.env.TINYBIRD_API_KEY ?? c.env.TINYBIRD_TOKEN ?? '', fetch });
|
|
5
|
+
}
|
|
6
|
+
export const cases = [
|
|
7
|
+
{
|
|
8
|
+
id: 'request-token-override-and-typed-error',
|
|
9
|
+
asks: 'a request token override is used instead of the Workspace token, and its refusal retains the status and JSON error in TinybirdApiError',
|
|
10
|
+
source: `${SDK} "Error thrown by TinybirdApi when a response is not OK"`,
|
|
11
|
+
async run(c) {
|
|
12
|
+
const { TinybirdApiError } = await import('@tinybirdco/sdk/api');
|
|
13
|
+
const api = await client(c);
|
|
14
|
+
try {
|
|
15
|
+
await api.ingest('client_case_events', { label: 'refused' }, { token: 'throwaway-invalid-token' });
|
|
16
|
+
return ['the invalid request token was accepted'];
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!(error instanceof TinybirdApiError))
|
|
20
|
+
return [`the refusal is not TinybirdApiError: ${String(error)}`];
|
|
21
|
+
const failures = [];
|
|
22
|
+
if (error.statusCode !== 403)
|
|
23
|
+
failures.push(`the invalid token answered ${error.statusCode}, not 403`);
|
|
24
|
+
if (!error.response?.error || error.message !== error.response.error)
|
|
25
|
+
failures.push('the vendor error message did not survive SDK decoding');
|
|
26
|
+
if (!error.responseBody || JSON.stringify(JSON.parse(error.responseBody)) !== JSON.stringify(error.response))
|
|
27
|
+
failures.push('the parsed error does not retain the raw vendor response');
|
|
28
|
+
return failures;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'date-rejection-and-ndjson-batch',
|
|
34
|
+
asks: 'Date objects are refused locally, then two valid event records are serialized as NDJSON without losing quoted text or line breaks',
|
|
35
|
+
source: `${SDK} "Date values are not supported in ingest payloads."`,
|
|
36
|
+
async run(c) {
|
|
37
|
+
let calls = 0;
|
|
38
|
+
const routed = ((input, init) => { calls++; return c.fetch(input, init); });
|
|
39
|
+
const api = await client(c, routed);
|
|
40
|
+
try {
|
|
41
|
+
await api.ingest('client_case_events', { timestamp: new Date('2024-01-15T10:30:00Z') });
|
|
42
|
+
return ['a Date object was accepted for ingestion'];
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (!(error instanceof Error) || !error.message.includes('Date values are not supported in ingest payloads.'))
|
|
46
|
+
return [`the Date refusal was ${String(error)}`];
|
|
47
|
+
if (calls !== 0)
|
|
48
|
+
return ['the SDK sent a request before refusing the Date object'];
|
|
49
|
+
}
|
|
50
|
+
await api.requestJson('/v0/datasources', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ name: 'client_case_events', schema: 'label String, timestamp DateTime' }).toString() });
|
|
51
|
+
const rows = [{ label: 'a "quoted" value', timestamp: '2024-01-15 10:30:00' }, { label: 'two\nlines', timestamp: '2024-01-15 10:31:00' }];
|
|
52
|
+
const ingested = await api.ingestBatch('client_case_events', rows, { wait: true });
|
|
53
|
+
const result = await api.sql('SELECT label, timestamp FROM client_case_events ORDER BY timestamp');
|
|
54
|
+
return ingested.successful_rows === 2 && ingested.quarantined_rows === 0 && JSON.stringify(result.data) === JSON.stringify(rows)
|
|
55
|
+
? [] : [`NDJSON batch/read-back differs: ${JSON.stringify({ ingested, rows: result.data })}`];
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'json-query-parameter-serialization',
|
|
60
|
+
asks: 'an object query parameter becomes JSON and the Endpoint uses its value rather than the object string representation',
|
|
61
|
+
source: `${SDK} "Plain objects (e.g. p.json() params) become JSON strings; Dates are rejected;"`,
|
|
62
|
+
async run(c) {
|
|
63
|
+
const api = await client(c);
|
|
64
|
+
await api.requestJson('/v0/datasources', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ name: 'client_json_rows', schema: 'name String' }).toString() });
|
|
65
|
+
await api.ingestBatch('client_json_rows', [{ name: 'selected' }, { name: 'excluded' }], { wait: true });
|
|
66
|
+
await api.requestJson('/v0/pipes', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'client_json_filter', nodes: [{ name: 'endpoint', type: 'endpoint', sql: "%\nSELECT name FROM client_json_rows WHERE name = {{JSON(filters, '{\"name\":\"selected\"}').get('name')}}" }] }) });
|
|
67
|
+
const result = await api.query('client_json_filter', { filters: { name: 'excluded' } });
|
|
68
|
+
return JSON.stringify(result.data) === JSON.stringify([{ name: 'excluded' }]) ? [] : [`the JSON parameter selected ${JSON.stringify(result.data)}`];
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The relay's unmodified client forwards a local application's HTTP responses through the control socket.
|
|
2
|
+
// Expectations are the pinned protocol and relay reference, never the pack author's scripted frames.
|
|
3
|
+
import { Agent } from 'node:https';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
export const cases = [{
|
|
6
|
+
id: 'local-http-relay',
|
|
7
|
+
asks: 'createTunnel preserves visitor method/body/public host and streams local responses, including HEAD, 204 and 304',
|
|
8
|
+
source: 'https://github.com/volter-ai/volter-tunnel/blob/c14b6e36722b20cbb3ee7245c7f555b5c12b376e/packages/core/src/protocol.ts "An inbound HTTP request to forward to the local server."',
|
|
9
|
+
async run(c) {
|
|
10
|
+
const failures = [];
|
|
11
|
+
const local = createServer(async (request, response) => {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for await (const chunk of request)
|
|
14
|
+
chunks.push(Buffer.from(chunk));
|
|
15
|
+
const path = request.url ?? '';
|
|
16
|
+
if (path === '/empty') {
|
|
17
|
+
response.writeHead(204);
|
|
18
|
+
response.end();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (path === '/cached') {
|
|
22
|
+
response.writeHead(304);
|
|
23
|
+
response.end();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (request.method === 'HEAD') {
|
|
27
|
+
response.writeHead(200, { 'x-local-head': 'present' });
|
|
28
|
+
response.end();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
response.writeHead(200, { 'content-type': 'application/json', 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'self'; frame-ancestors 'none'" });
|
|
32
|
+
response.write(JSON.stringify({ method: request.method, path, host: request.headers['x-forwarded-host'], appHeader: request.headers['x-volter-app'], body: Buffer.concat(chunks).toString() }));
|
|
33
|
+
response.end();
|
|
34
|
+
});
|
|
35
|
+
await new Promise((resolve) => local.listen(0, '127.0.0.1', resolve));
|
|
36
|
+
// The SDK owns its HTTPS agent. Route that agent's TCP connection as a World's injector does,
|
|
37
|
+
// retaining its secure relay URL and Host; local-origin connections retain their ordinary agent.
|
|
38
|
+
const original = Agent.prototype.createConnection;
|
|
39
|
+
Agent.prototype.createConnection = function (options, callback) {
|
|
40
|
+
if ((options.hostname ?? options.host) === c.host)
|
|
41
|
+
return c.connect();
|
|
42
|
+
return original.call(this, options, callback);
|
|
43
|
+
};
|
|
44
|
+
let tunnel;
|
|
45
|
+
try {
|
|
46
|
+
const { createTunnel } = await import('@volter/tunnel/client');
|
|
47
|
+
tunnel = await createTunnel({ host: `https://${c.host}`, port: local.address().port,
|
|
48
|
+
tunnelId: 'conform-harness', secret: c.env.TUNNEL_SECRET, authRequired: false,
|
|
49
|
+
logger: { info() { }, warn() { }, debug() { } }, });
|
|
50
|
+
if (tunnel.url !== 'https://conform-harness.voltertest.xyz')
|
|
51
|
+
failures.push('registered URL differs from the requested tunnel');
|
|
52
|
+
const answer = await c.fetch(`${tunnel.url}/command?view=demo`, { method: 'POST',
|
|
53
|
+
headers: { 'content-type': 'text/plain', 'x-volter-app': 'custom', origin: 'https://viewer.example' }, body: 'run demo' });
|
|
54
|
+
const body = await answer.json();
|
|
55
|
+
for (const [key, expected] of Object.entries({ method: 'POST', path: '/command?view=demo', host: 'conform-harness.voltertest.xyz', appHeader: 'custom', body: 'run demo' })) {
|
|
56
|
+
if (body[key] !== expected)
|
|
57
|
+
failures.push(`local request ${key}: ${JSON.stringify(body[key])}`);
|
|
58
|
+
}
|
|
59
|
+
// Node fetch injection rewrites the URL to the listener and carries the original vendor host.
|
|
60
|
+
const injected = await c.fetch('http://127.0.0.1/injected', { headers: {
|
|
61
|
+
'x-volter-twin-original-host': 'conform-harness.voltertest.xyz', 'x-volter-app': 'injected',
|
|
62
|
+
} });
|
|
63
|
+
if (injected.status !== 200)
|
|
64
|
+
failures.push('World-injected visitor did not reach its registered tunnel');
|
|
65
|
+
else {
|
|
66
|
+
const body = await injected.json();
|
|
67
|
+
if (body.host !== 'conform-harness.voltertest.xyz' || body.path !== '/injected' || body.appHeader !== 'injected') {
|
|
68
|
+
failures.push('World-injected visitor lost its public host or application headers');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (answer.headers.get('access-control-allow-origin') !== 'https://viewer.example')
|
|
72
|
+
failures.push('CORS origin not reflected');
|
|
73
|
+
if (answer.headers.has('x-frame-options') || answer.headers.get('content-security-policy')?.includes('frame-ancestors'))
|
|
74
|
+
failures.push('embedding restrictions not stripped');
|
|
75
|
+
if (answer.headers.get('cache-control') !== 'private, no-store, max-age=0')
|
|
76
|
+
failures.push('dynamic response cache policy missing');
|
|
77
|
+
for (const [path, method, status] of [['/empty', 'GET', 204], ['/cached', 'GET', 304], ['/head', 'HEAD', 200]]) {
|
|
78
|
+
const response = await c.fetch(`${tunnel.url}${path}`, { method });
|
|
79
|
+
if (response.status !== status || (await response.text()) !== '')
|
|
80
|
+
failures.push(`${method} ${path} is not bodyless ${status}`);
|
|
81
|
+
}
|
|
82
|
+
const preflight = await c.fetch(`${tunnel.url}/command`, { method: 'OPTIONS', headers: {
|
|
83
|
+
origin: 'https://viewer.example', 'access-control-request-headers': 'X-Demo',
|
|
84
|
+
} });
|
|
85
|
+
if (preflight.status !== 204 || preflight.headers.get('access-control-allow-headers') !== 'X-Demo')
|
|
86
|
+
failures.push('preflight does not reflect requested headers');
|
|
87
|
+
const tooLarge = await c.fetch(`${tunnel.url}/upload`, { method: 'POST', body: 'x'.repeat(750_001) });
|
|
88
|
+
if (tooLarge.status !== 413)
|
|
89
|
+
failures.push('oversized request was forwarded');
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
tunnel?.close();
|
|
93
|
+
Agent.prototype.createConnection = original;
|
|
94
|
+
local.closeAllConnections();
|
|
95
|
+
await new Promise((resolve) => local.close(() => resolve()));
|
|
96
|
+
}
|
|
97
|
+
return failures;
|
|
98
|
+
},
|
|
99
|
+
}];
|