@volter/twin-standard 1.0.30 → 1.0.32
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 +22 -16
- 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/protocol-3.d.ts +1 -0
- package/dist/src/protocol-3.js +76 -30
- 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 +20 -14
- package/src/index.ts +5 -3
- package/src/lanes.ts +7 -0
- package/src/p3-rules.ts +0 -2
- package/src/protocol-3.ts +71 -27
- 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,98 @@
|
|
|
1
|
+
// Slack's own clients (@slack/web-api, @slack/socket-mode), driving the slack pack as an application does: a workspace
|
|
2
|
+
// made and an app installed through Slack's own pages, then the app's Socket Mode client hearing a person's message in a
|
|
3
|
+
// channel its bot is in, and the app's files.uploadV2 putting a file in a thread (Volter Harness's Chat SDK adapter does
|
|
4
|
+
// both, journeys/demand.json). The expectations are the event and the upload as Slack documents them.
|
|
5
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
6
|
+
|
|
7
|
+
const MESSAGE = 'https://docs.slack.dev/reference/events/message.channels';
|
|
8
|
+
const UPLOADS = 'https://docs.slack.dev/messaging/working-with-files';
|
|
9
|
+
/** A message event's fields as Slack documents them (its example payload, and the fields a message carries). */
|
|
10
|
+
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']);
|
|
11
|
+
|
|
12
|
+
type Installed = { channel: string; botUser: string; botToken: string; appToken: string; person: string };
|
|
13
|
+
|
|
14
|
+
/** A workspace, a channel, and an app with Socket Mode subscribed to the channel's messages, installed by the workspace's
|
|
15
|
+
* owner through Slack's own pages and API, as a team sets one up. */
|
|
16
|
+
async function install(c: ClientContext): Promise<Installed> {
|
|
17
|
+
const person = 'xoxp-UMAYA';
|
|
18
|
+
const call = async (url: string, body: unknown, token?: string): Promise<Response> => c.fetch(url, { method: 'POST', redirect: 'manual', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) });
|
|
19
|
+
const json = async (r: Response): Promise<Record<string, any>> => (await r.json()) as Record<string, any>;
|
|
20
|
+
await call('https://slack.com/get-started', { email: 'maya@kiln.studio', name: 'Maya Okafor' }, person);
|
|
21
|
+
const channel = (await json(await call('https://slack.com/api/conversations.create', { name: 'studio' }, person))).channel?.id as string;
|
|
22
|
+
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];
|
|
23
|
+
if (!channel || !config) throw new Error(`the workspace or its app configuration token was not made (channel ${channel}, token ${config})`);
|
|
24
|
+
const app = await json(await call('https://slack.com/api/apps.manifest.create', { manifest: {
|
|
25
|
+
display_information: { name: 'Conformance Bot' },
|
|
26
|
+
features: { bot_user: { display_name: 'conformance_bot' } },
|
|
27
|
+
oauth_config: { redirect_urls: ['https://bot.example.com/slack/oauth'], scopes: { bot: ['chat:write', 'channels:history', 'files:write'] } },
|
|
28
|
+
settings: { socket_mode_enabled: true, event_subscriptions: { bot_events: ['message.channels'] } },
|
|
29
|
+
} }, config));
|
|
30
|
+
if (!app.ok) throw new Error(`apps.manifest.create: ${JSON.stringify(app).slice(0, 160)}`);
|
|
31
|
+
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);
|
|
32
|
+
const code = new URL(allowed.headers.get('location') ?? 'http://x/').searchParams.get('code');
|
|
33
|
+
if (!code) throw new Error(`the install was not allowed (${allowed.status})`);
|
|
34
|
+
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' }));
|
|
35
|
+
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];
|
|
36
|
+
if (!access.ok || !appToken) throw new Error(`the bot or app-level token was not issued (${JSON.stringify(access).slice(0, 120)})`);
|
|
37
|
+
await call('https://slack.com/api/conversations.invite', { channel, users: access.bot_user_id }, person);
|
|
38
|
+
return { channel, botUser: String(access.bot_user_id), botToken: String(access.access_token), appToken, person };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const cases: ClientCase[] = [
|
|
42
|
+
{
|
|
43
|
+
id: 'socket-mode-message',
|
|
44
|
+
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",
|
|
45
|
+
source: `${MESSAGE} "The semantics for this event"`,
|
|
46
|
+
async run(c) {
|
|
47
|
+
const installed = await install(c);
|
|
48
|
+
const { SocketModeClient } = await import('@slack/socket-mode');
|
|
49
|
+
const { WebClient, LogLevel } = await import('@slack/web-api');
|
|
50
|
+
const socket = new SocketModeClient({ appToken: installed.appToken, logLevel: LogLevel.ERROR, clientOptions: { agent: c.agent } });
|
|
51
|
+
const heard = new Promise<Record<string, unknown> | string>((resolve) => {
|
|
52
|
+
const timer = setTimeout(() => resolve('no message event in 10 s'), 10_000);
|
|
53
|
+
socket.on('message', async ({ event, ack }: { event: Record<string, unknown>; ack: () => Promise<void> }) => { await ack(); clearTimeout(timer); resolve(event); });
|
|
54
|
+
});
|
|
55
|
+
const started = await socket.start().then(() => undefined, (e: Error) => e.message);
|
|
56
|
+
if (started) return [`the Socket Mode client did not connect: ${started}`];
|
|
57
|
+
const person = new WebClient(installed.person, { agent: c.agent, logLevel: LogLevel.ERROR });
|
|
58
|
+
await person.chat.postMessage({ channel: installed.channel, text: 'The client brief is in.' });
|
|
59
|
+
const event = await heard;
|
|
60
|
+
await socket.disconnect().catch(() => undefined);
|
|
61
|
+
if (typeof event === 'string') return [event];
|
|
62
|
+
const out: string[] = [];
|
|
63
|
+
for (const k of ['type', 'channel', 'user', 'text', 'ts', 'event_ts', 'channel_type']) if (event[k] === undefined) out.push(`the event carries no ${k}, which Slack's payload has`);
|
|
64
|
+
for (const k of Object.keys(event)) if (!MESSAGE_FIELDS.has(k)) out.push(`the event carries ${k}, which no Slack message event has`);
|
|
65
|
+
if (event.text !== 'The client brief is in.') out.push(`the event's text is ${JSON.stringify(event.text)}`);
|
|
66
|
+
return out;
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'files-upload-v2',
|
|
71
|
+
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",
|
|
72
|
+
source: `${UPLOADS} "Post the contents of your file to the URL returned by the files.getUploadURLExternal API method"`,
|
|
73
|
+
async run(c) {
|
|
74
|
+
const installed = await install(c);
|
|
75
|
+
const { WebClient, LogLevel } = await import('@slack/web-api');
|
|
76
|
+
const bot = new WebClient(installed.botToken, { agent: c.agent, logLevel: LogLevel.ERROR });
|
|
77
|
+
const root = await bot.chat.postMessage({ channel: installed.channel, text: 'Render finished' });
|
|
78
|
+
if (!root.ts) return [`the thread's first message was not posted: ${JSON.stringify(root).slice(0, 160)}`];
|
|
79
|
+
const bytes = Buffer.from('frame,seconds\n1,0.42\n2,0.40\n');
|
|
80
|
+
let uploaded: Record<string, any>;
|
|
81
|
+
try {
|
|
82
|
+
// as the Chat SDK's Slack adapter calls it: the files in file_uploads, the thread's channel_id and thread_ts
|
|
83
|
+
uploaded = await bot.files.uploadV2({ channel_id: installed.channel, thread_ts: root.ts, file_uploads: [{ file: bytes, filename: 'timings.csv' }] }) as Record<string, any>;
|
|
84
|
+
} catch (e) { return [`files.uploadV2 failed: ${(e as Error).message}`]; }
|
|
85
|
+
const id = uploaded.files?.[0]?.files?.[0]?.id as string | undefined;
|
|
86
|
+
if (!uploaded.ok || !id) return [`files.uploadV2 answered no file: ${JSON.stringify(uploaded).slice(0, 200)}`];
|
|
87
|
+
const replies = await bot.conversations.replies({ channel: installed.channel, ts: root.ts });
|
|
88
|
+
const shared = (replies.messages ?? []).flatMap((m) => (m.files ?? []).map((f) => ({ f, thread: m.thread_ts }))).find((x) => x.f.id === id);
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
if (!shared) out.push(`the thread holds no message carrying file ${id}`);
|
|
91
|
+
else {
|
|
92
|
+
if (shared.thread !== root.ts) out.push(`the file's message is in thread ${String(shared.thread)}, not ${root.ts}`);
|
|
93
|
+
if (shared.f.size !== bytes.length) out.push(`the file's size is ${String(shared.f.size)}, not the ${bytes.length} bytes sent`);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
];
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
import type { AddressInfo } from 'node:net';
|
|
7
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
8
|
+
|
|
9
|
+
const SIGNATURE = 'https://docs.stripe.com/webhooks/signature';
|
|
10
|
+
const EVENT = 'https://docs.stripe.com/api/events/object';
|
|
11
|
+
const PAGINATION = 'https://docs.stripe.com/api/pagination/auto';
|
|
12
|
+
const IDEMPOTENCY = 'https://docs.stripe.com/api/idempotent_requests';
|
|
13
|
+
const ERRORS = 'https://docs.stripe.com/api/errors/handling';
|
|
14
|
+
const PORTAL = 'https://docs.stripe.com/api/customer_portal/sessions/object';
|
|
15
|
+
|
|
16
|
+
const failed = (what: string) => (e: unknown): Error => new Error(`${what} failed: ${(e as Error).name}: ${(e as Error).message}`);
|
|
17
|
+
|
|
18
|
+
async function stripe(c: ClientContext) {
|
|
19
|
+
const { default: Stripe } = await import('stripe');
|
|
20
|
+
return new Stripe(c.env.STRIPE_SECRET_KEY ?? '', { httpAgent: c.agent, maxNetworkRetries: 0, timeout: 10_000 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const cases: ClientCase[] = [
|
|
24
|
+
{
|
|
25
|
+
id: 'webhook-event-verified',
|
|
26
|
+
asks: "the event Stripe sends an endpoint is verified by its secret, and its data.object is the customer as the API answers it",
|
|
27
|
+
source: `${SIGNATURE} "use the Stripe-Signature header and call the constructEvent() function"; ${EVENT} "Object containing the API resource relevant to the event."`,
|
|
28
|
+
async run(c) {
|
|
29
|
+
// the application's endpoint: a server of this process, as an application's own
|
|
30
|
+
const received: Array<{ body: string; signature: string }> = [];
|
|
31
|
+
const receiver = createServer((req, res) => {
|
|
32
|
+
let body = '';
|
|
33
|
+
req.on('data', (d: Buffer) => { body += d; });
|
|
34
|
+
req.on('end', () => { received.push({ body, signature: String(req.headers['stripe-signature'] ?? '') }); res.end('{}'); });
|
|
35
|
+
});
|
|
36
|
+
await new Promise<void>((r) => receiver.listen(0, '127.0.0.1', () => r()));
|
|
37
|
+
const port = (receiver.address() as AddressInfo).port;
|
|
38
|
+
try {
|
|
39
|
+
const client = await stripe(c);
|
|
40
|
+
const endpoint = await client.webhookEndpoints.create({ url: `http://127.0.0.1:${port}/stripe`, enabled_events: ['customer.created'] });
|
|
41
|
+
const customer = await client.customers.create({ email: 'ada@example.com', name: 'Ada Lovelace' });
|
|
42
|
+
for (let i = 0; i < 50 && !received.length; i++) await new Promise((r) => setTimeout(r, 100));
|
|
43
|
+
const delivery = received[0];
|
|
44
|
+
if (!delivery) return ['no event reached the endpoint in 5 s'];
|
|
45
|
+
let event;
|
|
46
|
+
try { event = client.webhooks.constructEvent(delivery.body, delivery.signature, String(endpoint.secret)); } catch (e) { return [`constructEvent refused the delivery: ${(e as Error).message}`]; }
|
|
47
|
+
const out: string[] = [];
|
|
48
|
+
if (event.type !== 'customer.created') out.push(`the event is ${event.type}`);
|
|
49
|
+
const object = event.data.object as unknown as Record<string, unknown>;
|
|
50
|
+
if (object.id !== customer.id) out.push(`data.object is ${String(object.id)}, not the customer made (${customer.id})`);
|
|
51
|
+
const answered = await client.customers.retrieve(customer.id) as unknown as Record<string, unknown>;
|
|
52
|
+
for (const k of Object.keys(object)) if (!(k in answered)) out.push(`data.object carries ${k}, which the API's customer does not`);
|
|
53
|
+
return out;
|
|
54
|
+
} finally { receiver.close(); }
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'customers-auto-paginated',
|
|
59
|
+
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',
|
|
60
|
+
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."`,
|
|
61
|
+
async run(c) {
|
|
62
|
+
try {
|
|
63
|
+
const client = await stripe(c);
|
|
64
|
+
const made: string[] = [];
|
|
65
|
+
for (const name of ['Ada Lovelace', 'Grace Hopper', 'Katherine Johnson']) {
|
|
66
|
+
made.push((await client.customers.create({ email: 'team@analytical.example', name }).catch((e: unknown) => { throw failed('customers.create')(e); })).id);
|
|
67
|
+
}
|
|
68
|
+
const listed = await client.customers.list({ email: 'team@analytical.example', limit: 2 }).autoPagingToArray({ limit: 10 }).catch((e: unknown) => { throw failed('customers.list auto-pagination')(e); });
|
|
69
|
+
const ids = listed.map((x) => x.id);
|
|
70
|
+
const expected = [...made].reverse();
|
|
71
|
+
return ids.join(',') === expected.join(',') ? [] : [`auto-pagination read ${ids.join(', ') || 'nothing'}, not the three customers newest first (${expected.join(', ')})`];
|
|
72
|
+
} catch (e) { return [(e as Error).message]; }
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'idempotent-retry',
|
|
77
|
+
asks: 'a create retried with its idempotency key answers the first create\'s customer, and the key sent with other parameters is refused',
|
|
78
|
+
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."`,
|
|
79
|
+
async run(c) {
|
|
80
|
+
try {
|
|
81
|
+
const client = await stripe(c);
|
|
82
|
+
const key = 'client-case-signup-ada';
|
|
83
|
+
const first = await client.customers.create({ email: 'ada@example.com', name: 'Ada Lovelace' }, { idempotencyKey: key }).catch((e: unknown) => { throw failed('customers.create')(e); });
|
|
84
|
+
const retried = await client.customers.create({ email: 'ada@example.com', name: 'Ada Lovelace' }, { idempotencyKey: key }).catch((e: unknown) => { throw failed('customers.create, retried')(e); });
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
if (retried.id !== first.id) out.push(`the retry made ${retried.id}, not the first create's ${first.id}`);
|
|
87
|
+
const misuse = await client.customers.create({ email: 'grace@example.com', name: 'Grace Hopper' }, { idempotencyKey: key }).then(() => undefined, (e: unknown) => e as { type?: string; statusCode?: number });
|
|
88
|
+
if (!misuse) out.push('the key sent with other parameters made a customer');
|
|
89
|
+
else if (misuse.type !== 'StripeIdempotencyError') out.push(`the key sent with other parameters raised ${String(misuse.type)}, not StripeIdempotencyError`);
|
|
90
|
+
return out;
|
|
91
|
+
} catch (e) { return [(e as Error).message]; }
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'missing-object-error',
|
|
96
|
+
asks: 'retrieving a customer that does not exist raises the client\'s invalid-request error, 404, code resource_missing',
|
|
97
|
+
source: `${ERRORS} "Our client libraries raise exceptions for many reasons, such as a failed charge, invalid parameters, authentication errors, and network unavailability."`,
|
|
98
|
+
async run(c) {
|
|
99
|
+
try {
|
|
100
|
+
const client = await stripe(c);
|
|
101
|
+
const raised = await client.customers.retrieve('cus_never_made').then(() => undefined, (e: unknown) => e as { type?: string; statusCode?: number; code?: string });
|
|
102
|
+
if (!raised) return ['retrieving a customer that does not exist answered one'];
|
|
103
|
+
const out: string[] = [];
|
|
104
|
+
if (raised.type !== 'StripeInvalidRequestError') out.push(`it raised ${String(raised.type)}, not StripeInvalidRequestError`);
|
|
105
|
+
if (raised.statusCode !== 404) out.push(`its status is ${String(raised.statusCode)}, not 404`);
|
|
106
|
+
if (raised.code !== 'resource_missing') out.push(`its code is ${String(raised.code)}, not resource_missing`);
|
|
107
|
+
return out;
|
|
108
|
+
} catch (e) { return [(e as Error).message]; }
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'portal-session-opens',
|
|
113
|
+
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',
|
|
114
|
+
source: `${PORTAL} "The short-lived URL of the session that gives customers access to the customer portal."`,
|
|
115
|
+
async run(c) {
|
|
116
|
+
try {
|
|
117
|
+
const client = await stripe(c);
|
|
118
|
+
const customer = await client.customers.create({ email: 'billing@analytical.example', name: 'Analytical Engines' }).catch((e: unknown) => { throw failed('customers.create')(e); });
|
|
119
|
+
const session = await client.billingPortal.sessions.create({ customer: customer.id, return_url: 'https://app.analytical.example/billing' }).catch((e: unknown) => { throw failed('billingPortal.sessions.create')(e); });
|
|
120
|
+
const out: string[] = [];
|
|
121
|
+
if (session.return_url !== 'https://app.analytical.example/billing') out.push(`the session's return_url is ${String(session.return_url)}`);
|
|
122
|
+
if (!session.url.startsWith('https://billing.stripe.com/')) { out.push(`the session's url is ${session.url}, not on billing.stripe.com`); return out; }
|
|
123
|
+
const page = await c.fetch(session.url);
|
|
124
|
+
if (page.status !== 200) out.push(`the portal page answered ${page.status}`);
|
|
125
|
+
return out;
|
|
126
|
+
} catch (e) { return [(e as Error).message]; }
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Tinybird's official API wrapper, unmodified, with the World credential and routed fetch.
|
|
2
|
+
// The pinned package names Forward; its decoupled API wrapper uses the Classic /v0 APIs.
|
|
3
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
4
|
+
|
|
5
|
+
const SDK = 'https://unpkg.com/@tinybirdco/sdk@0.0.84/src/api/api.ts';
|
|
6
|
+
|
|
7
|
+
async function client(c: ClientContext, fetch: typeof globalThis.fetch = c.fetch) {
|
|
8
|
+
const { createTinybirdApi } = await import('@tinybirdco/sdk/api');
|
|
9
|
+
return createTinybirdApi({ baseUrl: `https://${c.host}`, token: c.env.TINYBIRD_API_KEY ?? c.env.TINYBIRD_TOKEN ?? '', fetch });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const cases: ClientCase[] = [
|
|
13
|
+
{
|
|
14
|
+
id: 'request-token-override-and-typed-error',
|
|
15
|
+
asks: 'a request token override is used instead of the Workspace token, and its refusal retains the status and JSON error in TinybirdApiError',
|
|
16
|
+
source: `${SDK} "Error thrown by TinybirdApi when a response is not OK"`,
|
|
17
|
+
async run(c) {
|
|
18
|
+
const { TinybirdApiError } = await import('@tinybirdco/sdk/api');
|
|
19
|
+
const api = await client(c);
|
|
20
|
+
try {
|
|
21
|
+
await api.ingest('client_case_events', { label: 'refused' }, { token: 'throwaway-invalid-token' });
|
|
22
|
+
return ['the invalid request token was accepted'];
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (!(error instanceof TinybirdApiError)) return [`the refusal is not TinybirdApiError: ${String(error)}`];
|
|
25
|
+
const failures: string[] = [];
|
|
26
|
+
if (error.statusCode !== 403) failures.push(`the invalid token answered ${error.statusCode}, not 403`);
|
|
27
|
+
if (!error.response?.error || error.message !== error.response.error) failures.push('the vendor error message did not survive SDK decoding');
|
|
28
|
+
if (!error.responseBody || JSON.stringify(JSON.parse(error.responseBody)) !== JSON.stringify(error.response)) failures.push('the parsed error does not retain the raw vendor response');
|
|
29
|
+
return failures;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'date-rejection-and-ndjson-batch',
|
|
35
|
+
asks: 'Date objects are refused locally, then two valid event records are serialized as NDJSON without losing quoted text or line breaks',
|
|
36
|
+
source: `${SDK} "Date values are not supported in ingest payloads."`,
|
|
37
|
+
async run(c) {
|
|
38
|
+
let calls = 0;
|
|
39
|
+
const routed = ((input: RequestInfo | URL, init?: RequestInit) => { calls++; return c.fetch(input, init); }) as typeof fetch;
|
|
40
|
+
const api = await client(c, routed);
|
|
41
|
+
try {
|
|
42
|
+
await api.ingest('client_case_events', { timestamp: new Date('2024-01-15T10:30:00Z') });
|
|
43
|
+
return ['a Date object was accepted for ingestion'];
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!(error instanceof Error) || !error.message.includes('Date values are not supported in ingest payloads.')) return [`the Date refusal was ${String(error)}`];
|
|
46
|
+
if (calls !== 0) return ['the SDK sent a request before refusing the Date object'];
|
|
47
|
+
}
|
|
48
|
+
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() });
|
|
49
|
+
const rows = [{ label: 'a "quoted" value', timestamp: '2024-01-15 10:30:00' }, { label: 'two\nlines', timestamp: '2024-01-15 10:31:00' }];
|
|
50
|
+
const ingested = await api.ingestBatch('client_case_events', rows, { wait: true });
|
|
51
|
+
const result = await api.sql('SELECT label, timestamp FROM client_case_events ORDER BY timestamp');
|
|
52
|
+
return ingested.successful_rows === 2 && ingested.quarantined_rows === 0 && JSON.stringify(result.data) === JSON.stringify(rows)
|
|
53
|
+
? [] : [`NDJSON batch/read-back differs: ${JSON.stringify({ ingested, rows: result.data })}`];
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'json-query-parameter-serialization',
|
|
58
|
+
asks: 'an object query parameter becomes JSON and the Endpoint uses its value rather than the object string representation',
|
|
59
|
+
source: `${SDK} "Plain objects (e.g. p.json() params) become JSON strings; Dates are rejected;"`,
|
|
60
|
+
async run(c) {
|
|
61
|
+
const api = await client(c);
|
|
62
|
+
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() });
|
|
63
|
+
await api.ingestBatch('client_json_rows', [{ name: 'selected' }, { name: 'excluded' }], { wait: true });
|
|
64
|
+
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')}}" }] }) });
|
|
65
|
+
const result = await api.query('client_json_filter', { filters: { name: 'excluded' } });
|
|
66
|
+
return JSON.stringify(result.data) === JSON.stringify([{ name: 'excluded' }]) ? [] : [`the JSON parameter selected ${JSON.stringify(result.data)}`];
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
];
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
import type { AddressInfo } from 'node:net';
|
|
6
|
+
import type { ClientCase } from './harness.ts';
|
|
7
|
+
|
|
8
|
+
export const cases: ClientCase[] = [{
|
|
9
|
+
id: 'local-http-relay',
|
|
10
|
+
asks: 'createTunnel preserves visitor method/body/public host and streams local responses, including HEAD, 204 and 304',
|
|
11
|
+
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."',
|
|
12
|
+
async run(c) {
|
|
13
|
+
const failures: string[] = [];
|
|
14
|
+
const local = createServer(async (request, response) => {
|
|
15
|
+
const chunks: Buffer[] = [];
|
|
16
|
+
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
17
|
+
const path = request.url ?? '';
|
|
18
|
+
if (path === '/empty') { response.writeHead(204); response.end(); return; }
|
|
19
|
+
if (path === '/cached') { response.writeHead(304); response.end(); return; }
|
|
20
|
+
if (request.method === 'HEAD') { response.writeHead(200, { 'x-local-head': 'present' }); response.end(); return; }
|
|
21
|
+
response.writeHead(200, { 'content-type': 'application/json', 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'self'; frame-ancestors 'none'" });
|
|
22
|
+
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() }));
|
|
23
|
+
response.end();
|
|
24
|
+
});
|
|
25
|
+
await new Promise<void>((resolve) => local.listen(0, '127.0.0.1', resolve));
|
|
26
|
+
// The SDK owns its HTTPS agent. Route that agent's TCP connection as a World's injector does,
|
|
27
|
+
// retaining its secure relay URL and Host; local-origin connections retain their ordinary agent.
|
|
28
|
+
const original = Agent.prototype.createConnection;
|
|
29
|
+
Agent.prototype.createConnection = function (options: any, callback: any): any {
|
|
30
|
+
if ((options.hostname ?? options.host) === c.host) return c.connect();
|
|
31
|
+
return original.call(this, options, callback);
|
|
32
|
+
};
|
|
33
|
+
let tunnel: { url: string; close(): void } | undefined;
|
|
34
|
+
try {
|
|
35
|
+
const { createTunnel } = await import('@volter/tunnel/client');
|
|
36
|
+
tunnel = await createTunnel({ host: `https://${c.host}`, port: (local.address() as AddressInfo).port,
|
|
37
|
+
tunnelId: 'conform-harness', secret: c.env.TUNNEL_SECRET, authRequired: false,
|
|
38
|
+
logger: { info() {}, warn() {}, debug() {} },
|
|
39
|
+
});
|
|
40
|
+
if (tunnel.url !== 'https://conform-harness.voltertest.xyz') failures.push('registered URL differs from the requested tunnel');
|
|
41
|
+
const answer = await c.fetch(`${tunnel.url}/command?view=demo`, { method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'text/plain', 'x-volter-app': 'custom', origin: 'https://viewer.example' }, body: 'run demo' });
|
|
43
|
+
const body = await answer.json() as Record<string, unknown>;
|
|
44
|
+
for (const [key, expected] of Object.entries({ method: 'POST', path: '/command?view=demo', host: 'conform-harness.voltertest.xyz', appHeader: 'custom', body: 'run demo' })) {
|
|
45
|
+
if (body[key] !== expected) failures.push(`local request ${key}: ${JSON.stringify(body[key])}`);
|
|
46
|
+
}
|
|
47
|
+
// Node fetch injection rewrites the URL to the listener and carries the original vendor host.
|
|
48
|
+
const injected = await c.fetch('http://127.0.0.1/injected', { headers: {
|
|
49
|
+
'x-volter-twin-original-host': 'conform-harness.voltertest.xyz', 'x-volter-app': 'injected',
|
|
50
|
+
} });
|
|
51
|
+
if (injected.status !== 200) failures.push('World-injected visitor did not reach its registered tunnel');
|
|
52
|
+
else {
|
|
53
|
+
const body = await injected.json() as Record<string, unknown>;
|
|
54
|
+
if (body.host !== 'conform-harness.voltertest.xyz' || body.path !== '/injected' || body.appHeader !== 'injected') {
|
|
55
|
+
failures.push('World-injected visitor lost its public host or application headers');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (answer.headers.get('access-control-allow-origin') !== 'https://viewer.example') failures.push('CORS origin not reflected');
|
|
59
|
+
if (answer.headers.has('x-frame-options') || answer.headers.get('content-security-policy')?.includes('frame-ancestors')) failures.push('embedding restrictions not stripped');
|
|
60
|
+
if (answer.headers.get('cache-control') !== 'private, no-store, max-age=0') failures.push('dynamic response cache policy missing');
|
|
61
|
+
for (const [path, method, status] of [['/empty', 'GET', 204], ['/cached', 'GET', 304], ['/head', 'HEAD', 200]] as const) {
|
|
62
|
+
const response = await c.fetch(`${tunnel.url}${path}`, { method });
|
|
63
|
+
if (response.status !== status || (await response.text()) !== '') failures.push(`${method} ${path} is not bodyless ${status}`);
|
|
64
|
+
}
|
|
65
|
+
const preflight = await c.fetch(`${tunnel.url}/command`, { method: 'OPTIONS', headers: {
|
|
66
|
+
origin: 'https://viewer.example', 'access-control-request-headers': 'X-Demo',
|
|
67
|
+
} });
|
|
68
|
+
if (preflight.status !== 204 || preflight.headers.get('access-control-allow-headers') !== 'X-Demo') failures.push('preflight does not reflect requested headers');
|
|
69
|
+
const tooLarge = await c.fetch(`${tunnel.url}/upload`, { method: 'POST', body: 'x'.repeat(750_001) });
|
|
70
|
+
if (tooLarge.status !== 413) failures.push('oversized request was forwarded');
|
|
71
|
+
} finally {
|
|
72
|
+
tunnel?.close();
|
|
73
|
+
Agent.prototype.createConnection = original;
|
|
74
|
+
local.closeAllConnections();
|
|
75
|
+
await new Promise<void>((resolve) => local.close(() => resolve()));
|
|
76
|
+
}
|
|
77
|
+
return failures;
|
|
78
|
+
},
|
|
79
|
+
}];
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Upstash's own clients, at the versions Dub pins (@upstash/redis 1.38.2, @upstash/ratelimit 2.0.6, @upstash/qstash
|
|
2
|
+
// 2.11.0), driving the upstash pack as Dub does, with the credentials the pack's credential door issued
|
|
3
|
+
// (UPSTASH_REDIS_REST_URL and _TOKEN, QSTASH_TOKEN and the two signing keys): the Redis client auto-pipelines the commands
|
|
4
|
+
// it is given and decodes base64 answers into the values it was given; the rate limiter runs its sliding window's script
|
|
5
|
+
// by SHA, then by EVAL, and refuses past its limit; QStash's client publishes, a duplicate deduplication id answering the
|
|
6
|
+
// first message, and each delivery carries a signature its Receiver verifies with the account's signing keys.
|
|
7
|
+
//
|
|
8
|
+
// The clients take no fetch of their own: they call the global one, which a World's injector routes, so each case routes
|
|
9
|
+
// it as the World does while it runs.
|
|
10
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
11
|
+
|
|
12
|
+
const AUTO_PIPELINE = 'https://upstash.com/docs/redis/sdks/ts/pipelining/auto-pipeline';
|
|
13
|
+
const ALGORITHMS = 'https://upstash.com/docs/redis/sdks/ratelimit-ts/algorithms';
|
|
14
|
+
const SIGNATURE = 'https://upstash.com/docs/qstash/howto/signature';
|
|
15
|
+
const DEDUPLICATION = 'https://upstash.com/docs/qstash/features/deduplication';
|
|
16
|
+
/** The QStash the credential door opens is in eu-central-1, whose URL is qstash.upstash.io (the pack's api lane). */
|
|
17
|
+
const QSTASH_URL = 'https://qstash.upstash.io';
|
|
18
|
+
|
|
19
|
+
async function routed<T>(c: ClientContext, run: () => Promise<T>): Promise<T> {
|
|
20
|
+
const held = globalThis.fetch;
|
|
21
|
+
globalThis.fetch = c.fetch;
|
|
22
|
+
try { return await run(); } finally { globalThis.fetch = held; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Redis = {
|
|
26
|
+
set: (key: string, value: unknown, opts?: Record<string, unknown>) => Promise<unknown>;
|
|
27
|
+
get: <T>(key: string) => Promise<T | null>;
|
|
28
|
+
incr: (key: string) => Promise<number>;
|
|
29
|
+
hset: (key: string, fields: Record<string, unknown>) => Promise<number>;
|
|
30
|
+
hgetall: <T>(key: string) => Promise<T | null>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A client as Dub's lib/upstash/redis.ts makes one: `new Redis({ url, token })`. */
|
|
34
|
+
async function redis(c: ClientContext): Promise<Redis> {
|
|
35
|
+
const { Redis } = (await import('@upstash/redis')) as unknown as { Redis: new (config: { url: string; token: string }) => Redis };
|
|
36
|
+
return new Redis({ url: c.env.UPSTASH_REDIS_REST_URL!, token: c.env.UPSTASH_REDIS_REST_TOKEN! });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Publish = { messageId: string; deduplicated?: boolean };
|
|
40
|
+
type QStash = { publishJSON: (request: Record<string, unknown>) => Promise<Publish> };
|
|
41
|
+
type Receiver = new (keys: { currentSigningKey: string; nextSigningKey: string }) => { verify: (r: { signature: string; body: string; url?: string }) => Promise<boolean> };
|
|
42
|
+
export const cases: ClientCase[] = [
|
|
43
|
+
{
|
|
44
|
+
id: 'redis-auto-pipeline',
|
|
45
|
+
asks: 'commands given at once are sent as one pipeline and each answers its own value, decoded from base64 into what was given: an object set is read back as that object, a counter as a number, a hash as an object',
|
|
46
|
+
source: `${AUTO_PIPELINE} "Auto pipelining allows you to use the Redis client as usual"; ${AUTO_PIPELINE} "the client will accumulate commands in a pipeline"`,
|
|
47
|
+
async run(c) {
|
|
48
|
+
if (!c.env.UPSTASH_REDIS_REST_URL || !c.env.UPSTASH_REDIS_REST_TOKEN) return ['the credential door issued no UPSTASH_REDIS_REST_URL and token'];
|
|
49
|
+
try {
|
|
50
|
+
return await routed(c, async () => {
|
|
51
|
+
const r = await redis(c);
|
|
52
|
+
const link = { url: 'https://trailmark.io/pricing', clicks: 0, tags: ['launch'] };
|
|
53
|
+
const [set, count, fields] = await Promise.all([r.set('link:go.trailmark.io:pricing', link, { ex: 3600 }), r.incr('clicks:pricing'), r.hset('checkout:cs_1', { amount: 4900, currency: 'usd' })]);
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
if (set !== 'OK') out.push(`SET answered ${JSON.stringify(set)}, not OK`);
|
|
56
|
+
if (count !== 1) out.push(`INCR answered ${JSON.stringify(count)}, not 1`);
|
|
57
|
+
if (fields !== 2) out.push(`HSET answered ${JSON.stringify(fields)}, not 2`);
|
|
58
|
+
const [read, hash] = await Promise.all([r.get<typeof link>('link:go.trailmark.io:pricing'), r.hgetall<Record<string, unknown>>('checkout:cs_1')]);
|
|
59
|
+
if (JSON.stringify(read) !== JSON.stringify(link)) out.push(`the link read back as ${JSON.stringify(read)}`);
|
|
60
|
+
if (JSON.stringify(hash) !== JSON.stringify({ amount: 4900, currency: 'usd' })) out.push(`the hash read back as ${JSON.stringify(hash)}`);
|
|
61
|
+
return out;
|
|
62
|
+
});
|
|
63
|
+
} catch (e) { return [`the client's calls failed: ${(e as Error).name}: ${(e as Error).message}`]; }
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'ratelimit-sliding-window',
|
|
68
|
+
asks: "a sliding window of 3 requests per 10 seconds, as Dub's ratelimit() makes one (prefix dub, analytics on), allows three requests and rejects the fourth; its script is run by SHA, and by EVAL when the database does not hold it",
|
|
69
|
+
source: `${ALGORITHMS} "the request is rejected"; ${ALGORITHMS} "Create a new ratelimiter, that allows 10 requests per 10 seconds."`,
|
|
70
|
+
async run(c) {
|
|
71
|
+
if (!c.env.UPSTASH_REDIS_REST_URL || !c.env.UPSTASH_REDIS_REST_TOKEN) return ['the credential door issued no UPSTASH_REDIS_REST_URL and token'];
|
|
72
|
+
try {
|
|
73
|
+
return await routed(c, async () => {
|
|
74
|
+
const { Ratelimit } = (await import('@upstash/ratelimit')) as unknown as { Ratelimit: { new (config: Record<string, unknown>): { limit: (id: string) => Promise<{ success: boolean; remaining: number; pending: Promise<unknown> }> }; slidingWindow: (tokens: number, window: string) => unknown } };
|
|
75
|
+
const limiter = new Ratelimit({ redis: await redis(c), limiter: Ratelimit.slidingWindow(3, '10 s'), analytics: true, prefix: 'dub', timeout: 1000 });
|
|
76
|
+
const seen: boolean[] = [];
|
|
77
|
+
for (let i = 0; i < 4; i += 1) {
|
|
78
|
+
const res = await limiter.limit('unsplash:203.0.113.7');
|
|
79
|
+
await res.pending;
|
|
80
|
+
seen.push(res.success);
|
|
81
|
+
}
|
|
82
|
+
return JSON.stringify(seen) === JSON.stringify([true, true, true, false]) ? [] : [`four requests in the window answered ${JSON.stringify(seen)}, not three allowed and the fourth rejected`];
|
|
83
|
+
});
|
|
84
|
+
} catch (e) { return [`the limiter's calls failed: ${(e as Error).name}: ${(e as Error).message}`]; }
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'qstash-deduplicated-publish',
|
|
89
|
+
asks: 'a message published again with the same deduplication id is accepted and answers the existing message\'s id',
|
|
90
|
+
source: `${DEDUPLICATION} "we will accept the request and return the messageID of the existing message"`,
|
|
91
|
+
async run(c) {
|
|
92
|
+
if (!c.env.QSTASH_TOKEN) return ['the credential door issued no QSTASH_TOKEN'];
|
|
93
|
+
try {
|
|
94
|
+
return await routed(c, async () => {
|
|
95
|
+
const { Client } = (await import('@upstash/qstash')) as unknown as { Client: new (config: { baseUrl: string; token: string }) => QStash };
|
|
96
|
+
const client = new Client({ baseUrl: QSTASH_URL, token: c.env.QSTASH_TOKEN! });
|
|
97
|
+
const request = { url: 'https://app.trailmark.io/api/cron/payouts/send-stripe-payout', body: { payoutId: 'po_1' }, deduplicationId: 'po_1', delay: 3600 };
|
|
98
|
+
const first = await client.publishJSON(request);
|
|
99
|
+
const again = await client.publishJSON(request);
|
|
100
|
+
const out: string[] = [];
|
|
101
|
+
if (!first.messageId) out.push('the first publish answered no messageId');
|
|
102
|
+
if (again.messageId !== first.messageId) out.push(`the duplicate answered ${JSON.stringify(again.messageId)}, not the first message's ${JSON.stringify(first.messageId)}`);
|
|
103
|
+
return out;
|
|
104
|
+
});
|
|
105
|
+
} catch (e) { return [`the client's calls failed: ${(e as Error).name}: ${(e as Error).message}`]; }
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'qstash-signed-delivery',
|
|
110
|
+
asks: "a published message's delivery carries Upstash-Signature, a JWT the Receiver verifies with the account's current and next signing keys against the body and the destination",
|
|
111
|
+
source: `${SIGNATURE} "We send a JWT with each request. This JWT is signed by your individual secret signing key and sent in the Upstash-Signature HTTP header."`,
|
|
112
|
+
async run(c) {
|
|
113
|
+
if (!c.env.QSTASH_TOKEN || !c.env.QSTASH_CURRENT_SIGNING_KEY || !c.env.QSTASH_NEXT_SIGNING_KEY) return ['the credential door issued no QSTASH_TOKEN and signing keys'];
|
|
114
|
+
try {
|
|
115
|
+
return await routed(c, async () => {
|
|
116
|
+
const { Client, Receiver } = (await import('@upstash/qstash')) as unknown as { Client: new (config: { baseUrl: string; token: string }) => QStash; Receiver: Receiver };
|
|
117
|
+
const client = new Client({ baseUrl: QSTASH_URL, token: c.env.QSTASH_TOKEN! });
|
|
118
|
+
const url = 'https://app.trailmark.io/api/jobs/process/welcome-user-job';
|
|
119
|
+
await client.publishJSON({ url, body: { name: 'welcome-user-job', userId: 'user_1' } });
|
|
120
|
+
// the World's stand-in for the destination: what QStash sent there (the pack's deliveries door)
|
|
121
|
+
let delivery: { headers: Record<string, string>; body: string } | undefined;
|
|
122
|
+
for (let i = 0; i < 20 && !delivery; i += 1) {
|
|
123
|
+
const seen = (await (await c.fetch(`${QSTASH_URL}/_twin/deliveries?to=${encodeURIComponent(url)}`)).json()) as { deliveries?: Array<{ headers: Record<string, string>; body: string }> };
|
|
124
|
+
delivery = seen.deliveries?.[0];
|
|
125
|
+
if (!delivery) await new Promise((r) => setTimeout(r, 100));
|
|
126
|
+
}
|
|
127
|
+
if (!delivery) return ['QStash made no delivery of the message'];
|
|
128
|
+
const signature = delivery.headers['upstash-signature'];
|
|
129
|
+
if (!signature) return ['the delivery carries no Upstash-Signature'];
|
|
130
|
+
const receiver = new Receiver({ currentSigningKey: c.env.QSTASH_CURRENT_SIGNING_KEY!, nextSigningKey: c.env.QSTASH_NEXT_SIGNING_KEY! });
|
|
131
|
+
const valid = await receiver.verify({ signature, body: delivery.body, url }).catch((e: Error) => e);
|
|
132
|
+
return valid === true ? [] : [`the Receiver refused the delivery's signature: ${valid instanceof Error ? valid.message : String(valid)}`];
|
|
133
|
+
});
|
|
134
|
+
} catch (e) { return [`the client's calls failed: ${(e as Error).name}: ${(e as Error).message}`]; }
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
];
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @volter/identity 0.4.1, as Volter Harness uses it; discovery and token verification are also the APIs Open Autonomy
|
|
2
|
+
// uses in 0.1.0. These cases are owned by conformance and are not run during pack authoring.
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { digest } from '@volter/world-core';
|
|
7
|
+
import { resolveIdentity, verifyIdentityToken, tokenFor, readSharedSignIn, SHARED_CLIENTS } from '@volter/identity';
|
|
8
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
9
|
+
|
|
10
|
+
const INDEX = 'https://unpkg.com/@volter/identity@0.4.1/dist/index.js';
|
|
11
|
+
const SHARED = 'https://unpkg.com/@volter/identity@0.4.1/dist/shared.js';
|
|
12
|
+
type Row = Record<string, any>;
|
|
13
|
+
const issuer = (c: ClientContext) => `https://${c.host}`;
|
|
14
|
+
async function send(c: ClientContext, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<Response> {
|
|
15
|
+
const response = await c.fetch(`${issuer(c)}${path}`, {
|
|
16
|
+
method: body === undefined ? 'GET' : 'POST', redirect: 'manual',
|
|
17
|
+
headers: { 'content-type': typeof body === 'string' ? 'application/x-www-form-urlencoded' : 'application/json', ...headers },
|
|
18
|
+
...(body === undefined ? {} : { body: typeof body === 'string' ? body : JSON.stringify(body) }),
|
|
19
|
+
});
|
|
20
|
+
if (response.status >= 400) throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
|
21
|
+
return response;
|
|
22
|
+
}
|
|
23
|
+
async function setup(c: ClientContext, name: string, shared = false): Promise<{ tokens: Row; client: string; subject: string }> {
|
|
24
|
+
const email = `${name}@sdk.example.test`;
|
|
25
|
+
const person = await (await send(c, '/_twin/users', { email, name })).json() as Row;
|
|
26
|
+
const login = await send(c, '/login', `email=${encodeURIComponent(email)}&authorize=`);
|
|
27
|
+
const cookie = login.headers.get('set-cookie')!.split(';')[0]!;
|
|
28
|
+
const admin = await (await send(c, '/_twin/app-credentials', {})).json() as Row;
|
|
29
|
+
const operator = { cookie, authorization: `Bearer ${admin.admin_token}` };
|
|
30
|
+
const callback = 'http://127.0.0.1:53716/callback';
|
|
31
|
+
const registered = shared
|
|
32
|
+
? await (await send(c, '/_twin/shared-client', {})).json() as Row
|
|
33
|
+
: await (await send(c, '/admin/clients', { client_name: name, redirect_uris: [callback], application_type: 'native', token_endpoint_auth_method: 'client_secret_post', grant_types: ['authorization_code', 'refresh_token'], skip_consent: true }, operator)).json() as Row;
|
|
34
|
+
if (shared) {
|
|
35
|
+
await send(c, '/admin/resources', { identifier: 'https://teams.sdk.example', name: 'SDK Teams server' }, operator);
|
|
36
|
+
await send(c, `/admin/clients/${registered.client_id}/resources`, { resource: 'https://teams.sdk.example' }, operator);
|
|
37
|
+
}
|
|
38
|
+
const verifier = 'official-sdk-case-verifier-with-enough-entropy-for-s256';
|
|
39
|
+
const query = new URLSearchParams({ client_id: registered.client_id, redirect_uri: callback, response_type: 'code', scope: 'openid profile email offline_access', code_challenge: digest('sha256', verifier, 'base64url'), code_challenge_method: 'S256' });
|
|
40
|
+
const authorized = await send(c, `/api/auth/oauth2/authorize?${query}`, undefined, { cookie });
|
|
41
|
+
const code = new URL(authorized.headers.get('location')!).searchParams.get('code')!;
|
|
42
|
+
const form = new URLSearchParams({ client_id: registered.client_id, redirect_uri: callback, grant_type: 'authorization_code', code, code_verifier: verifier, ...(registered.client_secret ? { client_secret: registered.client_secret } : {}) });
|
|
43
|
+
const tokens = await (await send(c, '/api/auth/oauth2/token', form.toString())).json() as Row;
|
|
44
|
+
return { tokens, client: registered.client_id, subject: person.id };
|
|
45
|
+
}
|
|
46
|
+
async function withTransport<T>(c: ClientContext, run: () => Promise<T>): Promise<T> {
|
|
47
|
+
const previous = globalThis.fetch;
|
|
48
|
+
globalThis.fetch = c.fetch;
|
|
49
|
+
try { return await run(); } finally { globalThis.fetch = previous; }
|
|
50
|
+
}
|
|
51
|
+
export const cases: ClientCase[] = [
|
|
52
|
+
{
|
|
53
|
+
id: 'discovery-cache-and-fresh',
|
|
54
|
+
asks: 'resolveIdentity normalizes the issuer, caches discovery and refreshes when fresh is requested',
|
|
55
|
+
source: `${INDEX} "The answer is cached per issuer"`,
|
|
56
|
+
async run(c) {
|
|
57
|
+
let requests = 0;
|
|
58
|
+
const transport = (async (...args: Parameters<typeof fetch>) => { requests++; return c.fetch(...args); }) as typeof fetch;
|
|
59
|
+
const first = await resolveIdentity(`${issuer(c)}/`, { fresh: true, fetch: transport });
|
|
60
|
+
const cached = await resolveIdentity(issuer(c), { fetch: transport });
|
|
61
|
+
const failures: string[] = [];
|
|
62
|
+
if (!first.available || !cached.available) failures.push('the SDK did not discover the twin');
|
|
63
|
+
if (first.available && first.metadata.issuer !== issuer(c)) failures.push('the SDK discovered another issuer');
|
|
64
|
+
if (requests !== 1) failures.push('a cached discovery made another request');
|
|
65
|
+
await resolveIdentity(issuer(c), { fresh: true, fetch: transport });
|
|
66
|
+
if (Number(requests) !== 2) failures.push('fresh discovery did not request metadata again');
|
|
67
|
+
return failures;
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'verify-signed-identity-and-audience',
|
|
72
|
+
asks: 'verifyIdentityToken reads the issuer keys, names the real subject and rejects another audience',
|
|
73
|
+
source: `${INDEX} "the signature, the issuer, the audience (this product's client id, or the resource it serves) and the expiry. Throws on anything else."`,
|
|
74
|
+
run(c) { return withTransport(c, async () => {
|
|
75
|
+
const issued = await setup(c, 'verified-person');
|
|
76
|
+
const who = await verifyIdentityToken(issued.tokens.id_token, { issuer: issuer(c), audience: issued.client, fetch: c.fetch });
|
|
77
|
+
const failures = who.subject === issued.subject && who.issuer === issuer(c) ? [] : ['the verified token names another subject or issuer'];
|
|
78
|
+
const wrong = await verifyIdentityToken(issued.tokens.id_token, { issuer: issuer(c), audience: 'another-client', fetch: c.fetch }).then(() => false, () => true);
|
|
79
|
+
if (!wrong) failures.push('the SDK accepted a token for another audience');
|
|
80
|
+
const tampered = await verifyIdentityToken(`${issued.tokens.id_token.slice(0, -4)}AAAA`, { issuer: issuer(c), audience: issued.client, fetch: c.fetch }).then(() => false, () => true);
|
|
81
|
+
if (!tampered) failures.push('the SDK accepted a changed signature');
|
|
82
|
+
return failures;
|
|
83
|
+
}); },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'shared-resource-refresh-rotation',
|
|
87
|
+
asks: 'tokenFor exchanges the shared refresh for the resource, saves the rotated refresh and uses it next time',
|
|
88
|
+
source: `${SHARED} "The token is the product's alone"`,
|
|
89
|
+
run(c) { return withTransport(c, async () => {
|
|
90
|
+
const issued = await setup(c, 'shared-person', true);
|
|
91
|
+
const directory = mkdtempSync(join(tmpdir(), 'identity-sdk-case-'));
|
|
92
|
+
const path = join(directory, 'identity.json');
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(path, JSON.stringify({ issuer: issuer(c), clientId: SHARED_CLIENTS[issuer(c)], refreshToken: issued.tokens.refresh_token, savedAt: Date.now() }), { mode: 0o600 });
|
|
95
|
+
const resource = 'https://teams.sdk.example';
|
|
96
|
+
const token = await tokenFor(resource, { path });
|
|
97
|
+
if (!token) return ['the SDK obtained no resource token'];
|
|
98
|
+
const who = await verifyIdentityToken(token, { issuer: issuer(c), audience: resource, fetch: c.fetch });
|
|
99
|
+
const saved = readSharedSignIn(path)!;
|
|
100
|
+
const failures = who.subject === issued.subject ? [] : ['the resource token names another subject'];
|
|
101
|
+
if (saved.refreshToken === issued.tokens.refresh_token) failures.push('the SDK did not save the rotated refresh');
|
|
102
|
+
if (!await tokenFor(resource, { path })) failures.push('the saved refresh did not obtain the next token');
|
|
103
|
+
if (JSON.parse(readFileSync(path, 'utf8')).refreshToken === saved.refreshToken) failures.push('the second refresh did not rotate again');
|
|
104
|
+
return failures;
|
|
105
|
+
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
106
|
+
}); },
|
|
107
|
+
},
|
|
108
|
+
];
|