@superdurable/connectors-sdk 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +264 -2
- package/dist/chunk-2IDXFKMY.js +251 -0
- package/dist/chunk-2NBM7P4T.js +42 -0
- package/dist/chunk-KD5DYYSU.js +93 -0
- package/dist/chunk-SOXYPIMO.js +179 -0
- package/dist/connectormock/index.d.ts +181 -0
- package/dist/connectormock/index.js +350 -0
- package/dist/credentials-vrvW0d4j.d.ts +445 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +376 -0
- package/dist/oauthtoken/index.d.ts +90 -0
- package/dist/oauthtoken/index.js +236 -0
- package/dist/operation-Ca8rfacf.d.ts +34 -0
- package/dist/projectconfig/index.d.ts +569 -0
- package/dist/projectconfig/index.js +1433 -0
- package/dist/providerhttp/index.d.ts +57 -0
- package/dist/providerhttp/index.js +26 -0
- package/dist/singledispatch/index.d.ts +72 -0
- package/dist/singledispatch/index.js +178 -0
- package/package.json +73 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Super Durable
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,265 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Connector TypeScript SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@superdurable/connectors-sdk` is the provider-neutral TypeScript SDK for Dex connectors. It provides stable value contracts, typed Query and Mutation operations, Dex Step factories, credential refresh, OAuth token exchanges, HTTP safety helpers, project configuration loading, scripted mocks, and single-dispatch marker decisions. The package is at version `0.1.1`, requires Node 22 or 24, and uses `@superdurable/dex` 1.28.0 or newer. It is ESM-only, matching Dex: use `import`; direct `require()` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`.
|
|
4
|
+
|
|
5
|
+
Install dependencies and verify the package from this directory:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm ci
|
|
9
|
+
npm run check
|
|
10
|
+
node examples/core.mjs
|
|
11
|
+
node examples/operations.mjs
|
|
12
|
+
node examples/steps.mjs
|
|
13
|
+
node examples/progress.mjs
|
|
14
|
+
node examples/providerhttp.mjs
|
|
15
|
+
node examples/projectconfig.mjs
|
|
16
|
+
node examples/refreshing_project.mjs
|
|
17
|
+
node examples/shared_project.mjs
|
|
18
|
+
node examples/connectormock.mjs
|
|
19
|
+
node examples/oauthtoken.mjs
|
|
20
|
+
node examples/singledispatch.mjs
|
|
21
|
+
node examples/fence.mjs
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
All durations in this package are milliseconds. Public JSON shapes use the Go SDK's field names. Credential values and project objects stay outside Flow state and cannot be serialized through their wrappers.
|
|
25
|
+
|
|
26
|
+
The [Dex integration harness](test/integ/step_flows.mjs) exercises the same Step factories through a real Worker and Client. Start `dexcli dev --open=false --dex-port 18891` with a local Dex server, set `DEX_SERVER_ADDRESS` when its gRPC address differs from `127.0.0.1:18891`, and run `npm run test:integration`. It verifies a happy path, optional branch, unwired optional branch failure, retry then success, retry exhaustion, uncertain mutation, a lost-response single-dispatch fence, repeated claim heartbeats, result Attribute persistence, progress and text Stream delivery, and scripted mock replay. It uses fixture operations and no provider credentials. CI runs the same harness in `sdkts-integration` using its once-per-run resolved Dex CLI.
|
|
27
|
+
|
|
28
|
+
## Typed operations and credential resolution
|
|
29
|
+
|
|
30
|
+
The [runnable operation example](examples/operations.mjs) validates a Query definition, binds a live Step context to stable call identity, resolves credentials at invocation time, and completes a Go-compatible zero receipt. `Mutation` adds an idempotency-key method and may return `newMutationUncertain` only for a dispatched write that cannot be repeated or confirmed by read-back. `runQuery` and `runMutation` throw `RetryError` for a safe retry; a Step factory turns that into Dex retry behavior. The example uses a test context; application Steps receive `AsyncContext` from Dex.
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import { createCall, newQueryBranch, runQuery, StaticCredentialProvider, validateQueryDefinition, zeroReceipt } from '../dist/index.js';
|
|
34
|
+
|
|
35
|
+
const definition = {
|
|
36
|
+
operation: { connectorId: 'sample', operationId: 'getItem' },
|
|
37
|
+
branches: [{ id: 'found', description: 'Item was found' }, { id: 'defect', description: 'Connector defect', optional: true }],
|
|
38
|
+
stepDefaults: {},
|
|
39
|
+
};
|
|
40
|
+
validateQueryDefinition(definition);
|
|
41
|
+
const context = { flowId: 'example-flow', stepExecutionId: 'getItem:1', attempt: 1 }; // Test context; a real Step receives Dex AsyncContext.
|
|
42
|
+
const call = createCall(context, { provider: 'sample', name: 'default' }, definition.operation);
|
|
43
|
+
const credentials = new StaticCredentialProvider([[call.connection, { apiKey: 'test-only' }]]);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`Call.context` is the live Dex `AsyncContext` object attached as a non-enumerable field by `createCall`; do not put it in Flow input or persisted state. Hosts with coordinated refresh implement `RefreshingCredentialProvider` and optionally `RejectedCredentialRefreshingProvider`. `resolveCredential` selects refresh support when present; `resolveCredentialAfterRejection` forces one coordinated refresh after an explicit authentication rejection.
|
|
47
|
+
|
|
48
|
+
## Dex Step factories
|
|
49
|
+
|
|
50
|
+
The [runnable Step factory example](examples/steps.mjs) builds Query and Mutation Steps with instance-addressed branch targets. `newQueryStep` and `newMutationStep` validate required routes at construction; optional unwired routes force-fail the Flow if selected. Their `getStepOptions()` applies the connector's one-hour Execute retry policy and accepts Execute-only overrides. A provider retry becomes a Dex retry; a provider delay of at least one second is floored to whole seconds before `retryAfter` is thrown. Register each Step instance in a Dex `StepList`, along with its target Steps and any result Attribute used by the Flow.
|
|
51
|
+
|
|
52
|
+
Dex CLI 1.28.0 introduces TypeScript Flow analysis for FDG 2.0 and Dex Web connector release identity. A connector package supplies a static `dex-connector.json` manifest, and the consuming Flow source declares `// dex:group` and `// dex:explanation` beside each Step class. The generic SDK factories expose `StepAnnotations` and `configurationUI()` to applications; those values alone do not create a connector manifest. This SDK's integration harness verifies runtime Step behavior but does not verify FDG generation, which requires a consuming connector package and the CLI 1.28.0 binary.
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { StepRef } from '@superdurable/dex';
|
|
56
|
+
import { goToBranch, newQueryBranch, newQueryStep } from '../dist/index.js';
|
|
57
|
+
|
|
58
|
+
const query = newQueryStep({
|
|
59
|
+
stepType: 'GetItem', annotations, connection, mapToOperationInput: input => input,
|
|
60
|
+
operation: { definition: () => ({ operation: { connectorId: 'example', operationId: 'getItem' },
|
|
61
|
+
branches: [{ id: 'found', description: 'Item found' }, { id: 'defect', description: 'Connector defect', optional: true }], stepDefaults: {} }),
|
|
62
|
+
invoke: (_call, input) => newQueryBranch('found', { id: input.id }) },
|
|
63
|
+
branches: [goToBranch('found', new StepRef('QueryDone'))],
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Progress and text Streams
|
|
68
|
+
|
|
69
|
+
The [runnable progress example](examples/progress.mjs) shows `Call.reportProgress` and `Call.writeText` with test Streams. Both methods are no-ops when their Stream is omitted. In a real Flow, pass `progressStream`, `textStream`, and optional `textOptions` to a Query or Mutation Step factory, then register both Streams in that Flow's persistence schema. `reportProgress` adds the stable call ID, Execute attempt, and one-based sequence; `writeText` uses Dex's buffered text writer. The real Worker test in the integration harness verifies delivery and ordering.
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
const call = createCall(context, { provider: 'example', name: 'default' }, operation, { progressStream, textStream });
|
|
73
|
+
const query = {
|
|
74
|
+
definition: () => ({ operation, branches: [{ id: 'found', description: 'Found' }, { id: 'defect', description: 'Defect', optional: true }], stepDefaults: {} }),
|
|
75
|
+
invoke: (providerCall, input) => {
|
|
76
|
+
providerCall.reportProgress({ phase: 'reading', percent: 0 });
|
|
77
|
+
providerCall.writeText('Found ');
|
|
78
|
+
providerCall.writeText(input.id);
|
|
79
|
+
providerCall.reportProgress({ phase: 'finished', percent: 1 });
|
|
80
|
+
return newQueryBranch('found', { id: input.id });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Core contracts, errors, and credentials
|
|
86
|
+
|
|
87
|
+
The [complete runnable core example](examples/core.mjs) validates a stable call ID and receipt, constructs a branch result, selects the default one-hour retry policy, and reveals a secret only at the provider boundary. `newMutationRetry` requests another safe attempt; `newMutationUncertain` is reserved for a dispatched write that cannot be repeated or read back. A definite business result selects a branch.
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
import { CallIDSchema, ReceiptSchema, newQueryBranch, defaultConnectorStepExecuteRetryPolicy, SecretString } from '../dist/index.js';
|
|
91
|
+
|
|
92
|
+
const callId = CallIDSchema.parse('6750e62f-4d15-4e7e-a9d3-ec90f4f17b35');
|
|
93
|
+
const receipt = ReceiptSchema.parse({ callId, provider: 'fixture', observedAt: new Date().toISOString() });
|
|
94
|
+
const result = newQueryBranch('found', { title: 'Example' }, receipt);
|
|
95
|
+
const retryPolicy = defaultConnectorStepExecuteRetryPolicy();
|
|
96
|
+
const credential = new SecretString('test-only');
|
|
97
|
+
if (result.kind !== 'branch' || retryPolicy.totalDurationMs !== 3600000 || credential.reveal() !== 'test-only') throw new Error('core example failed');
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Provider HTTP
|
|
101
|
+
|
|
102
|
+
The [complete runnable HTTP example](examples/providerhttp.mjs) sends one bounded request to a local server. `sendProviderRequest` resolves every address before connecting, pins the chosen address for IP literals and hostnames, and returns redirects without following them. `ProviderTransportError.isProvablyNotSent` is true for DNS, TCP connect, or TLS handshake failures before a usable connection could send HTTP bytes. Failures after connection or request writing have an unknown dispatch outcome, matching the Go connectors' DNS, connect, TLS, and obtained-connection trace.
|
|
103
|
+
`BodyTooLargeError` preserves the response `status` and `headers`, so a caller can classify a 429 or 503 and its `Retry-After` even when the response body exceeds its limit.
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
import { createServer } from 'node:http';
|
|
107
|
+
import { parseRetryAfter, sendProviderRequest } from '../dist/providerhttp/index.js';
|
|
108
|
+
|
|
109
|
+
const server = createServer((_request, response) => {
|
|
110
|
+
response.writeHead(429, { 'Retry-After': '7', 'Content-Type': 'application/json' });
|
|
111
|
+
response.end('{"error":{"code":"RATE_LIMIT"}}');
|
|
112
|
+
});
|
|
113
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
114
|
+
try {
|
|
115
|
+
const address = server.address();
|
|
116
|
+
const response = await sendProviderRequest({
|
|
117
|
+
url: `http://127.0.0.1:${address.port}/`, method: 'GET', maxResponseBytes: 1024,
|
|
118
|
+
addressPolicy: 'privateNetworkHTTP',
|
|
119
|
+
});
|
|
120
|
+
if (response.status !== 429 || parseRetryAfter(response.headers['retry-after']) !== 7000) throw new Error('HTTP example failed');
|
|
121
|
+
} finally {
|
|
122
|
+
await new Promise(resolve => server.close(resolve));
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Project configuration
|
|
127
|
+
|
|
128
|
+
The [complete runnable local configuration example](examples/projectconfig.mjs) loads Dex Web's mode-0600 `connections.json` and resolves credentials only at use time. Hosted use selects `DEX_PROJECT_*` environment settings, validates an exact snapshot digest, and uses a versioned S3 bucket with conditional writes. `encodeGoCompatibleJSON` produces Go-compatible storage bytes with sorted map keys and HTML-safe strings. Shared entries resolve through the credential broker; reauthorization and revocation terminate credential use, and expired local or hosted credentials require refresh.
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
132
|
+
import { tmpdir } from 'node:os';
|
|
133
|
+
import { join } from 'node:path';
|
|
134
|
+
import { loadFromEnvironment } from '../dist/projectconfig/index.js';
|
|
135
|
+
|
|
136
|
+
const directory = await mkdtemp(join(tmpdir(), 'sdkts-example-'));
|
|
137
|
+
const path = join(directory, 'connections.json');
|
|
138
|
+
try {
|
|
139
|
+
await writeFile(path, JSON.stringify({
|
|
140
|
+
schemaVersion: 'connectors.dex.dev/local-connections/v1alpha1',
|
|
141
|
+
connections: [{ connectorId: 'example', connectionName: 'default', modulePath: 'example',
|
|
142
|
+
provider: 'example', configuration: {}, credentials: { apiKey: 'test-only' } }],
|
|
143
|
+
}), { mode: 0o600 });
|
|
144
|
+
const project = await loadFromEnvironment({ DEX_CONNECTOR_CONFIG_FILE: path });
|
|
145
|
+
const credential = await project.resolveCredential('example', 'default');
|
|
146
|
+
if (credential.apiKey !== 'test-only') throw new Error('project configuration example failed');
|
|
147
|
+
} finally {
|
|
148
|
+
await rm(directory, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Project credential providers
|
|
153
|
+
|
|
154
|
+
The [runnable refresh example](examples/refreshing_project.mjs) binds `newRefreshingCredentialProvider` to a named project connection. `newCredentialProvider` reads an API key or another nonrenewable credential on each call. A refresh provider writes a `REFRESHING` head with a monotonic fence before sending one provider request, publishes an immutable result, and lets observers recover a result whose final head write was lost. An expired `AUTHORIZING` head publishes its immutable result when present or fences the connection as `REAUTHORIZATION_REQUIRED`; observers reload after competing writes. A terminal provider response also fences the connection as `REAUTHORIZATION_REQUIRED`; a transient response restores the prior credential. The local Dex Web projection writes an accepted refresh back to its mode-0600 `connections.json` only while that file still holds the imported credential. The decoder and encoder must cover the complete private credential, including a renewal token omitted by the provider's response.
|
|
155
|
+
|
|
156
|
+
For a loaded shared connection, the same provider reads the selected host connection through the credential broker. The [runnable shared connection example](examples/shared_project.mjs) passes a fifth `decodeShared` function to handle broker material without host-kept renewal secrets. `resolveWithRefresh` never invokes the local refresh driver for a shared connection. `resolveAfterRejection` asks the broker for a newer revision; broker reauthorization and selection failures stop the call.
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
import { createCall } from '../dist/index.js';
|
|
160
|
+
import { loadFromEnvironment, newRefreshingCredentialProvider } from '../dist/projectconfig/index.js';
|
|
161
|
+
|
|
162
|
+
const project = await loadFromEnvironment({ DEX_CONNECTOR_CONFIG_FILE: path });
|
|
163
|
+
const provider = newRefreshingCredentialProvider(
|
|
164
|
+
project.connections, { connectorId: 'example', connectionName: 'default' },
|
|
165
|
+
value => ({ accessToken: String(value.accessToken), refreshToken: String(value.refreshToken) }),
|
|
166
|
+
value => ({ accessToken: value.accessToken, refreshToken: value.refreshToken }),
|
|
167
|
+
);
|
|
168
|
+
const context = { flowId: 'example-flow', stepExecutionId: 'refresh:1', attempt: 1 };
|
|
169
|
+
const call = createCall(context, { provider: 'example', name: 'default' }, { connectorId: 'example', operationId: 'getItem' });
|
|
170
|
+
const credentials = await provider.resolveWithRefresh(call, {
|
|
171
|
+
refreshRequired: () => false,
|
|
172
|
+
refresh: async state => ({
|
|
173
|
+
credentials: { accessToken: 'new', refreshToken: state.credentials.refreshToken },
|
|
174
|
+
expiresAt: new Date(Date.now() + 3600_000),
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
const provider = newRefreshingCredentialProvider(project.connections, { connectorId: 'example', connectionName: 'shared' },
|
|
181
|
+
value => {
|
|
182
|
+
if (typeof value.accessToken !== 'string' || typeof value.refreshToken !== 'string') throw new TypeError();
|
|
183
|
+
return { accessToken: value.accessToken, refreshToken: value.refreshToken };
|
|
184
|
+
}, value => ({ ...value }),
|
|
185
|
+
value => {
|
|
186
|
+
if (typeof value.accessToken !== 'string') throw new TypeError();
|
|
187
|
+
return { accessToken: value.accessToken, refreshToken: '' };
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Connector mocks
|
|
192
|
+
|
|
193
|
+
The [complete runnable mock example](examples/connectormock.mjs) validates a JSON fixture, builds a scripted mutation from the real operation definition, and invokes it through the same result validator as a provider operation. `retryCase` and `uncertainCase` model the other provider outcomes; `decodeCallReport` reads the Go-compatible test harness report. Scripts support `respond`, `when`, `default`, `strict`, and `forFlow`. Mutation terminal outcomes replay by stable idempotency key; retries consume the next case. After a real Dex Flow completes, call `assertNoFailures()` to surface unscripted calls or invalid cases.
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
import { z } from 'zod';
|
|
197
|
+
import { branchCase, decodeOutput, newMutation } from '../dist/connectormock/index.js';
|
|
198
|
+
import { createCall, runMutation } from '../dist/index.js';
|
|
199
|
+
|
|
200
|
+
const output = decodeOutput('{"id":"item-1"}', z.strictObject({ id: z.string() }));
|
|
201
|
+
const response = branchCase('created', output);
|
|
202
|
+
if (response.kind !== 'branch' || response.value.id !== 'item-1') throw new Error('mock example failed');
|
|
203
|
+
|
|
204
|
+
const definition = { operation: { connectorId: 'sample', operationId: 'createItem' }, branches: [{ id: 'created', description: 'Created' }, { id: 'defect', description: 'Connector defect', optional: true }], stepDefaults: {} };
|
|
205
|
+
const mutation = newMutation(definition).respond(branchCase('created', output));
|
|
206
|
+
const context = { flowId: 'example-flow', stepExecutionId: 'createItem:1', attempt: 1 }; // Test context; a real Step receives Dex AsyncContext.
|
|
207
|
+
const call = createCall(context, { provider: 'sample', name: 'default' }, definition.operation);
|
|
208
|
+
const result = await runMutation(mutation, call, { id: 'item-1' });
|
|
209
|
+
if (result.branch !== 'created' || mutation.calls().length !== 1) throw new Error('scripted mock example failed');
|
|
210
|
+
mutation.assertNoFailures();
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## OAuth token refresh
|
|
214
|
+
|
|
215
|
+
The [runnable OAuth exchange example](examples/oauthtoken.mjs) shows a provider-neutral refresh-token grant. The injected `fetch` returns a local fixture; a connector normally uses the provider's fixed HTTPS token endpoint. `TokenEndpoint` also supports client-credentials and JWT-bearer grants, optional Basic authentication, declared response fields, bounded responses, and terminal OAuth error-code classification. A connector-specific `CredentialRefreshDriver` owns the credential shape; the host owns serialized persistence and must keep an old refresh token when the provider omits a replacement.
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
import { SecretString } from '../dist/index.js';
|
|
219
|
+
import { TokenEndpoint, isRefreshRequiredWithIssueTime } from '../dist/oauthtoken/index.js';
|
|
220
|
+
|
|
221
|
+
const endpoint = new TokenEndpoint({
|
|
222
|
+
providerName: 'Example', url: 'https://auth.example.test/token',
|
|
223
|
+
terminalErrorCodes: ['invalid_grant'],
|
|
224
|
+
fetch: async () => Response.json({ access_token: 'new-access', expires_in: 3600, token_type: 'Bearer' }),
|
|
225
|
+
});
|
|
226
|
+
const token = await endpoint.exchangeRefreshToken(
|
|
227
|
+
{ id: 'app-id', secret: new SecretString('test-client-secret'), authenticationMethod: 'client_secret_post' },
|
|
228
|
+
new SecretString('test-refresh-token'),
|
|
229
|
+
);
|
|
230
|
+
const issuedAt = new Date();
|
|
231
|
+
const expiresAt = new Date(issuedAt.getTime() + token.expiresInMs);
|
|
232
|
+
if (isRefreshRequiredWithIssueTime(true, issuedAt, expiresAt, issuedAt, 'refresh')) throw new Error('fresh token was counted as expired');
|
|
233
|
+
if (token.nextRefreshToken(new SecretString('test-refresh-token')).reveal() !== 'test-refresh-token') throw new Error('refresh token was lost');
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Single-dispatch markers
|
|
237
|
+
|
|
238
|
+
The [runnable marker example](examples/singledispatch.mjs) demonstrates the Go-compatible heartbeat checkpoint decision. The [runnable fence example](examples/fence.mjs) records a claim before one provider write, repeats it every five seconds while the write runs, and checkpoints an accepted provider ID for read-back. Construct one `Fence` per sync-durable Mutation Step attempt, call `decide()` before credentials or provider lookups, and use `recordNotSent()` for a pre-send Retry. `dispatch()` passes an `AbortSignal` bounded by the smaller of the Execute timeout and public `Context.deadline`, preserving time for outcome classification. Use `recordCanResend()` only after positive evidence the provider did not process a request. A failed claim checkpoint prevents the send; an unconfirmed claim on a later attempt permits read-back only.
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
import { decideDispatch } from '../dist/singledispatch/index.js';
|
|
242
|
+
|
|
243
|
+
const callId = '5af8fafa-06ff-5b2d-bb13-04693809fabf';
|
|
244
|
+
const first = decideDispatch(callId, 1, undefined);
|
|
245
|
+
const later = decideDispatch(callId, 2, { callId, attempt: 1, state: 'claimed' });
|
|
246
|
+
if (first.kind !== 'sendAllowed' || later.kind !== 'readBackRequired') throw new Error('single-dispatch decision example failed');
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
import { newFence } from '../dist/singledispatch/index.js';
|
|
251
|
+
|
|
252
|
+
const fence = newFence(call, 10_000);
|
|
253
|
+
if (fence.decide().kind !== 'sendAllowed') throw new Error('write was unexpectedly fenced');
|
|
254
|
+
const result = await fence.dispatch(async signal => {
|
|
255
|
+
if (signal.aborted || checkpoints.at(-1)?.state !== 'claimed') throw new Error('claim was not recorded before dispatch');
|
|
256
|
+
return { providerId: 'item-1' };
|
|
257
|
+
});
|
|
258
|
+
await fence.recordProviderId(result.providerId);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Release notes
|
|
262
|
+
|
|
263
|
+
### 0.1.0
|
|
264
|
+
|
|
265
|
+
First public ESM-only TypeScript Connector SDK release: Go-compatible contracts and conformance fixtures, safe provider HTTP, project configuration with durable credential refresh and guarded local writeback, typed operations and Dex Step factories, OAuth exchanges, scripted mocks, and the live single-dispatch fence. No live provider authorization has been tested.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// src/providerhttp/index.ts
|
|
2
|
+
import { lookup as lookupAddresses } from "dns/promises";
|
|
3
|
+
import { isIP as isIP2 } from "net";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import https from "https";
|
|
6
|
+
|
|
7
|
+
// src/netpolicy.ts
|
|
8
|
+
import { BlockList, isIP } from "net";
|
|
9
|
+
var loopback = new BlockList();
|
|
10
|
+
loopback.addSubnet("127.0.0.0", 8, "ipv4");
|
|
11
|
+
loopback.addAddress("::1", "ipv6");
|
|
12
|
+
var privateAddresses = new BlockList();
|
|
13
|
+
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
|
14
|
+
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
|
15
|
+
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
|
16
|
+
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
|
17
|
+
privateAddresses.addAddress("::1", "ipv6");
|
|
18
|
+
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
|
19
|
+
var linkLocal = new BlockList();
|
|
20
|
+
linkLocal.addSubnet("169.254.0.0", 16, "ipv4");
|
|
21
|
+
linkLocal.addSubnet("fe80::", 10, "ipv6");
|
|
22
|
+
var sharedAddressSpace = new BlockList();
|
|
23
|
+
sharedAddressSpace.addSubnet("100.64.0.0", 10, "ipv4");
|
|
24
|
+
var multicast = new BlockList();
|
|
25
|
+
multicast.addSubnet("224.0.0.0", 4, "ipv4");
|
|
26
|
+
multicast.addSubnet("ff00::", 8, "ipv6");
|
|
27
|
+
function unbracketHostname(hostname) {
|
|
28
|
+
return hostname.replace(/^\[|\]$/g, "");
|
|
29
|
+
}
|
|
30
|
+
function contains(list, hostname) {
|
|
31
|
+
const address = unbracketHostname(hostname);
|
|
32
|
+
const family = isIP(address);
|
|
33
|
+
return family !== 0 && list.check(address, family === 4 ? "ipv4" : "ipv6");
|
|
34
|
+
}
|
|
35
|
+
function isLoopbackIPAddressLiteral(hostname) {
|
|
36
|
+
return contains(loopback, hostname);
|
|
37
|
+
}
|
|
38
|
+
function isPrivateIPAddressLiteral(hostname) {
|
|
39
|
+
return contains(privateAddresses, hostname);
|
|
40
|
+
}
|
|
41
|
+
function isLinkLocalIPAddressLiteral(hostname) {
|
|
42
|
+
return contains(linkLocal, hostname);
|
|
43
|
+
}
|
|
44
|
+
function isAlwaysRefusedIPAddressLiteral(hostname) {
|
|
45
|
+
const address = unbracketHostname(hostname);
|
|
46
|
+
return address === "0.0.0.0" || address === "::" || address.toLowerCase() === "fd00:ec2::254" || contains(linkLocal, address) || contains(multicast, address);
|
|
47
|
+
}
|
|
48
|
+
function isAllowedProviderIPAddress(address, protocol, policy) {
|
|
49
|
+
if (isIP(unbracketHostname(address)) === 0 || isAlwaysRefusedIPAddressLiteral(address)) return false;
|
|
50
|
+
if (policy === "publicHTTPS") return protocol === "https:" && !isPrivateIPAddressLiteral(address) && !contains(sharedAddressSpace, address);
|
|
51
|
+
return protocol === "https:" || isPrivateIPAddressLiteral(address);
|
|
52
|
+
}
|
|
53
|
+
function isPrivateBrokerHost(hostname) {
|
|
54
|
+
if (isIP(unbracketHostname(hostname)) !== 0) return isPrivateIPAddressLiteral(hostname) || isLinkLocalIPAddressLiteral(hostname);
|
|
55
|
+
return hostname === "localhost" || hostname === "host.docker.internal" || !hostname.includes(".") || hostname.endsWith(".svc") || hostname.endsWith(".svc.cluster.local");
|
|
56
|
+
}
|
|
57
|
+
function isLocalStorageHost(hostname) {
|
|
58
|
+
if (isIP(unbracketHostname(hostname)) !== 0) return isPrivateIPAddressLiteral(hostname);
|
|
59
|
+
return hostname === "localhost" || hostname === "host.docker.internal" || hostname.endsWith(".svc.cluster.local");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/providerhttp/index.ts
|
|
63
|
+
var MaxErrorBodyBytes = 64 << 10;
|
|
64
|
+
var MaxRetryAfterDelayMs = 60 * 60 * 1e3;
|
|
65
|
+
var BodyTooLargeError = class extends Error {
|
|
66
|
+
/** HTTP status available when a response body was being read. */
|
|
67
|
+
status;
|
|
68
|
+
/** HTTP headers available when a response body was being read. */
|
|
69
|
+
headers;
|
|
70
|
+
constructor(status, headers) {
|
|
71
|
+
super("provider response body exceeds its size limit");
|
|
72
|
+
this.name = "BodyTooLargeError";
|
|
73
|
+
this.status = status;
|
|
74
|
+
this.headers = headers;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
var AddressRefusal = class extends Error {
|
|
78
|
+
/** Name a nonsecret network policy reason. */
|
|
79
|
+
constructor(reason) {
|
|
80
|
+
super(`address not allowed: ${reason}`);
|
|
81
|
+
this.reason = reason;
|
|
82
|
+
this.name = "AddressRefusal";
|
|
83
|
+
}
|
|
84
|
+
reason;
|
|
85
|
+
};
|
|
86
|
+
var ProviderTransportError = class extends Error {
|
|
87
|
+
/** True only when the request failed before a usable connection could send HTTP bytes. */
|
|
88
|
+
constructor(isProvablyNotSent) {
|
|
89
|
+
super("provider request failed in transport");
|
|
90
|
+
this.isProvablyNotSent = isProvablyNotSent;
|
|
91
|
+
this.name = "ProviderTransportError";
|
|
92
|
+
}
|
|
93
|
+
isProvablyNotSent;
|
|
94
|
+
};
|
|
95
|
+
function parseRetryAfter(value, now = /* @__PURE__ */ new Date()) {
|
|
96
|
+
const trimmed = value.trim();
|
|
97
|
+
if (/^[0-9]+$/.test(trimmed)) {
|
|
98
|
+
const seconds = Number(trimmed);
|
|
99
|
+
return Number.isSafeInteger(seconds) ? Math.min(seconds * 1e3, MaxRetryAfterDelayMs) : MaxRetryAfterDelayMs;
|
|
100
|
+
}
|
|
101
|
+
const weekday = "(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)";
|
|
102
|
+
const month = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)";
|
|
103
|
+
const time = "[0-9]{2}:[0-9]{2}:[0-9]{2}";
|
|
104
|
+
const isHTTPDate = new RegExp(`^${weekday}, [0-9]{2} ${month} [0-9]{4} ${time} GMT$`).test(trimmed) || new RegExp(`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), [0-9]{2}-${month}-[0-9]{2} ${time} GMT$`).test(trimmed) || new RegExp(`^${weekday} ${month} {1,2}[0-9]{1,2} ${time} [0-9]{4}$`).test(trimmed);
|
|
105
|
+
if (!isHTTPDate) return 0;
|
|
106
|
+
const timestamp = Date.parse(trimmed);
|
|
107
|
+
return Number.isNaN(timestamp) ? 0 : Math.min(Math.max(0, timestamp - now.getTime()), MaxRetryAfterDelayMs);
|
|
108
|
+
}
|
|
109
|
+
function validateBaseURL(value) {
|
|
110
|
+
if (!value) throw new TypeError("base URL is required");
|
|
111
|
+
if (!/^[!-~]+$/.test(value)) throw new TypeError("base URL must be printable ASCII without spaces");
|
|
112
|
+
if (value.includes("?") || value.includes("#")) throw new TypeError("base URL cannot contain a query or a fragment");
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = new URL(value);
|
|
116
|
+
} catch {
|
|
117
|
+
throw new TypeError("base URL must be an absolute URL with a host");
|
|
118
|
+
}
|
|
119
|
+
if (!parsed.hostname) throw new TypeError("base URL must be an absolute URL with a host");
|
|
120
|
+
if (parsed.username || parsed.password) throw new TypeError("base URL cannot contain user information");
|
|
121
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && (parsed.hostname === "localhost" || isLoopbackIPAddressLiteral(parsed.hostname)))) {
|
|
122
|
+
throw new TypeError("base URL must use HTTPS unless its host is loopback");
|
|
123
|
+
}
|
|
124
|
+
return value.replace(/\/+$/, "");
|
|
125
|
+
}
|
|
126
|
+
function isHeaderSafeCredential(value) {
|
|
127
|
+
return /^[!-~]+$/.test(value);
|
|
128
|
+
}
|
|
129
|
+
async function readBoundedBody(body, maxBytes) {
|
|
130
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("body limit must be a non-negative integer");
|
|
131
|
+
const chunks = [];
|
|
132
|
+
let length = 0;
|
|
133
|
+
for await (const chunk of body) {
|
|
134
|
+
length += chunk.length;
|
|
135
|
+
if (length > maxBytes) throw new BodyTooLargeError();
|
|
136
|
+
chunks.push(chunk);
|
|
137
|
+
}
|
|
138
|
+
return Buffer.concat(chunks, length);
|
|
139
|
+
}
|
|
140
|
+
function readErrorTokens(body, pointers) {
|
|
141
|
+
let document;
|
|
142
|
+
try {
|
|
143
|
+
document = JSON.parse(Buffer.from(body).toString("utf8"));
|
|
144
|
+
} catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
const tokens = [];
|
|
148
|
+
for (const pointer of pointers) {
|
|
149
|
+
const value = resolvePointer(document, pointer);
|
|
150
|
+
if ((typeof value === "string" || typeof value === "number") && /^[A-Za-z0-9_.:-]{1,64}$/.test(String(value))) tokens.push(String(value));
|
|
151
|
+
}
|
|
152
|
+
return tokens;
|
|
153
|
+
}
|
|
154
|
+
async function sendProviderRequest(request) {
|
|
155
|
+
if (!Number.isSafeInteger(request.maxResponseBytes) || request.maxResponseBytes < 0) throw new RangeError("response body limit must be a non-negative integer");
|
|
156
|
+
if (request.addressPolicy !== "publicHTTPS" && request.addressPolicy !== "privateNetworkHTTP") throw new AddressRefusal("address policy is invalid");
|
|
157
|
+
let parsed;
|
|
158
|
+
try {
|
|
159
|
+
parsed = new URL(request.url);
|
|
160
|
+
} catch {
|
|
161
|
+
throw new AddressRefusal("URL is invalid");
|
|
162
|
+
}
|
|
163
|
+
if (parsed.username || parsed.password || !["http:", "https:"].includes(parsed.protocol)) throw new AddressRefusal("URL scheme or user information is not allowed");
|
|
164
|
+
if (request.addressPolicy === "publicHTTPS" && parsed.protocol !== "https:") throw new AddressRefusal("plain http is not allowed");
|
|
165
|
+
if (request.timeoutMs !== void 0 && (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs < 0)) throw new RangeError("request timeout must be non-negative milliseconds");
|
|
166
|
+
const timeoutSignal = request.timeoutMs ? AbortSignal.timeout(request.timeoutMs) : void 0;
|
|
167
|
+
const signal = timeoutSignal && request.signal ? AbortSignal.any([timeoutSignal, request.signal]) : timeoutSignal ?? request.signal;
|
|
168
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
169
|
+
let addresses;
|
|
170
|
+
try {
|
|
171
|
+
addresses = isIP2(hostname) ? [{ address: hostname, family: isIP2(hostname) }] : await lookupAddresses(hostname, { all: true, verbatim: true });
|
|
172
|
+
} catch {
|
|
173
|
+
throw new ProviderTransportError(true);
|
|
174
|
+
}
|
|
175
|
+
if (addresses.length === 0 || addresses.some(({ address }) => !isAllowedProviderIPAddress(address, parsed.protocol, request.addressPolicy))) {
|
|
176
|
+
throw new AddressRefusal("resolved address violates policy");
|
|
177
|
+
}
|
|
178
|
+
const selected = addresses[0];
|
|
179
|
+
if (!selected) throw new AddressRefusal("host has no resolved address");
|
|
180
|
+
const options = {
|
|
181
|
+
method: request.method,
|
|
182
|
+
headers: request.headers,
|
|
183
|
+
signal,
|
|
184
|
+
lookup: (_hostname, options2, callback) => options2?.all ? callback(null, [{ address: selected.address, family: selected.family }]) : callback(null, selected.address, selected.family)
|
|
185
|
+
};
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
let hasConnected = false;
|
|
188
|
+
let hasWrittenRequest = false;
|
|
189
|
+
let clientRequest;
|
|
190
|
+
try {
|
|
191
|
+
clientRequest = (parsed.protocol === "https:" ? https : http).request(parsed, options, (response) => {
|
|
192
|
+
void readBoundedBody(response, request.maxResponseBytes).then((body) => {
|
|
193
|
+
resolve({ status: response.statusCode ?? 0, headers: response.headers, body });
|
|
194
|
+
}).catch((error) => {
|
|
195
|
+
response.destroy();
|
|
196
|
+
reject(error instanceof BodyTooLargeError ? new BodyTooLargeError(response.statusCode ?? 0, response.headers) : new ProviderTransportError(false));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
reject(new ProviderTransportError(true));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
clientRequest.on("socket", (socket) => {
|
|
204
|
+
if (!socket.connecting) hasConnected = true;
|
|
205
|
+
socket.once(parsed.protocol === "https:" ? "secureConnect" : "connect", () => {
|
|
206
|
+
hasConnected = true;
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
clientRequest.on("finish", () => {
|
|
210
|
+
hasWrittenRequest = true;
|
|
211
|
+
});
|
|
212
|
+
clientRequest.on("error", () => reject(new ProviderTransportError(!hasConnected && !hasWrittenRequest)));
|
|
213
|
+
try {
|
|
214
|
+
clientRequest.end(request.body);
|
|
215
|
+
} catch {
|
|
216
|
+
reject(new ProviderTransportError(!hasConnected && !hasWrittenRequest));
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function resolvePointer(document, pointer) {
|
|
221
|
+
if (pointer === "") return document;
|
|
222
|
+
if (!pointer.startsWith("/")) return void 0;
|
|
223
|
+
let current = document;
|
|
224
|
+
for (const encoded of pointer.slice(1).split("/")) {
|
|
225
|
+
if (/~(?![01])/.test(encoded)) return void 0;
|
|
226
|
+
const segment = encoded.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
227
|
+
if (Array.isArray(current)) {
|
|
228
|
+
if (!/^(0|[1-9][0-9]*)$/.test(segment)) return void 0;
|
|
229
|
+
current = current[Number(segment)];
|
|
230
|
+
} else if (current !== null && typeof current === "object") {
|
|
231
|
+
current = current[segment];
|
|
232
|
+
} else return void 0;
|
|
233
|
+
}
|
|
234
|
+
return current;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export {
|
|
238
|
+
isPrivateBrokerHost,
|
|
239
|
+
isLocalStorageHost,
|
|
240
|
+
MaxErrorBodyBytes,
|
|
241
|
+
MaxRetryAfterDelayMs,
|
|
242
|
+
BodyTooLargeError,
|
|
243
|
+
AddressRefusal,
|
|
244
|
+
ProviderTransportError,
|
|
245
|
+
parseRetryAfter,
|
|
246
|
+
validateBaseURL,
|
|
247
|
+
isHeaderSafeCredential,
|
|
248
|
+
readBoundedBody,
|
|
249
|
+
readErrorTokens,
|
|
250
|
+
sendProviderRequest
|
|
251
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DefectBranchID,
|
|
3
|
+
OperationRefSchema,
|
|
4
|
+
UncertainBranchID
|
|
5
|
+
} from "./chunk-SOXYPIMO.js";
|
|
6
|
+
|
|
7
|
+
// src/operation.ts
|
|
8
|
+
function validateQueryDefinition(definition) {
|
|
9
|
+
validateDefinition(definition);
|
|
10
|
+
if (definition.branches.some((branch) => branch.id === UncertainBranchID)) throw new TypeError("query cannot declare the uncertain branch");
|
|
11
|
+
}
|
|
12
|
+
function validateMutationDefinition(definition) {
|
|
13
|
+
validateDefinition(definition);
|
|
14
|
+
}
|
|
15
|
+
function validateDefinition(definition) {
|
|
16
|
+
OperationRefSchema.parse(definition.operation);
|
|
17
|
+
if (definition.branches.length === 0) throw new TypeError("at least one branch is required");
|
|
18
|
+
const branchIds = /* @__PURE__ */ new Set();
|
|
19
|
+
for (const branch of definition.branches) {
|
|
20
|
+
if (!/^[a-z][A-Za-z0-9]+$/.test(branch.id)) throw new TypeError(`branch ID ${branch.id} must be lower camel case`);
|
|
21
|
+
if (branchIds.has(branch.id)) throw new TypeError(`branch ID ${branch.id} is duplicated`);
|
|
22
|
+
if (!branch.description.trim()) throw new TypeError(`branch ${branch.id} description is required`);
|
|
23
|
+
branchIds.add(branch.id);
|
|
24
|
+
}
|
|
25
|
+
if (!branchIds.has(DefectBranchID)) throw new TypeError("defect branch is not declared");
|
|
26
|
+
const defaults = definition.stepDefaults;
|
|
27
|
+
for (const duration of [defaults.executeMethodTimeoutMs, defaults.heartbeatTimeoutMs]) {
|
|
28
|
+
if (duration !== void 0 && (!Number.isFinite(duration) || duration < 0)) throw new TypeError("Step timeouts cannot be negative");
|
|
29
|
+
}
|
|
30
|
+
if (defaults.executeDurability !== void 0 && !["default", "sync", "async"].includes(defaults.executeDurability)) throw new TypeError("Step Execute durability is invalid");
|
|
31
|
+
if (defaults.executeRetry !== void 0) {
|
|
32
|
+
const retry = defaults.executeRetry;
|
|
33
|
+
for (const value of [retry.initialIntervalMs, retry.maximumIntervalMs, retry.totalDurationMs, retry.maximumAttempts, retry.backoffCoefficient]) {
|
|
34
|
+
if (!Number.isFinite(value) || value < 0) throw new TypeError("Step retry fields cannot be negative");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
validateQueryDefinition,
|
|
41
|
+
validateMutationDefinition
|
|
42
|
+
};
|