@comms-id/asx 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +30 -0
- package/dist/browser.d.ts +5 -0
- package/dist/browser.js +6 -0
- package/dist/descriptor.d.ts +15 -0
- package/dist/descriptor.js +20 -0
- package/dist/fixtures.d.ts +5 -0
- package/dist/fixtures.js +10 -0
- package/dist/generated/client.d.ts +10 -0
- package/dist/generated/client.js +7 -0
- package/dist/generated/fixtures.d.ts +2 -0
- package/dist/generated/fixtures.js +16 -0
- package/dist/generated/operations.d.ts +6 -0
- package/dist/generated/operations.js +894 -0
- package/dist/generated/types.d.ts +135 -0
- package/dist/generated/types.js +2 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/runtime/browser-core.d.ts +23 -0
- package/dist/runtime/browser-core.js +41 -0
- package/dist/runtime/environment.d.ts +5 -0
- package/dist/runtime/environment.js +30 -0
- package/dist/runtime/errors.d.ts +29 -0
- package/dist/runtime/errors.js +40 -0
- package/dist/runtime/fixture-core.d.ts +7 -0
- package/dist/runtime/fixture-core.js +26 -0
- package/dist/runtime/http.d.ts +34 -0
- package/dist/runtime/http.js +165 -0
- package/dist/runtime/server-core.d.ts +26 -0
- package/dist/runtime/server-core.js +39 -0
- package/dist/runtime/sign.d.ts +22 -0
- package/dist/runtime/sign.js +41 -0
- package/dist/runtime/validate.d.ts +57 -0
- package/dist/runtime/validate.js +175 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +8 -0
- package/package.json +62 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** The body of lookup. */
|
|
2
|
+
export type LookupRequest = {
|
|
3
|
+
/** The ASX ticker, for example 'BHP'. */
|
|
4
|
+
readonly ticker: string;
|
|
5
|
+
/** live: ASX now; snapshot: the stored directory; live-first: ASX, then the snapshot on failure. */
|
|
6
|
+
readonly mode?: "live" | "snapshot" | "live-first";
|
|
7
|
+
};
|
|
8
|
+
/** The reply of lookup. */
|
|
9
|
+
export type LookupResponse = {
|
|
10
|
+
readonly status: "found";
|
|
11
|
+
readonly source: "live";
|
|
12
|
+
readonly observation: {
|
|
13
|
+
readonly company: {
|
|
14
|
+
readonly raw?: Readonly<Record<string, unknown>>;
|
|
15
|
+
readonly ticker: string;
|
|
16
|
+
readonly sourceId: string;
|
|
17
|
+
readonly companyName: string;
|
|
18
|
+
readonly address: string | null;
|
|
19
|
+
readonly tel: string | null;
|
|
20
|
+
readonly fax: string | null;
|
|
21
|
+
readonly website: string | null;
|
|
22
|
+
readonly description: string | null;
|
|
23
|
+
readonly issueType: string | null;
|
|
24
|
+
readonly foreignExempt: boolean | null;
|
|
25
|
+
readonly people: ReadonlyArray<{
|
|
26
|
+
readonly sourceName: string;
|
|
27
|
+
readonly displayName: string;
|
|
28
|
+
readonly title: string;
|
|
29
|
+
readonly sourceCategory: "directors" | "secretaries";
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
32
|
+
readonly startedAt: string;
|
|
33
|
+
readonly observedAt: string;
|
|
34
|
+
readonly lastChangedAt: string;
|
|
35
|
+
};
|
|
36
|
+
} | {
|
|
37
|
+
readonly status: "missing";
|
|
38
|
+
readonly source: "live";
|
|
39
|
+
readonly startedAt: string;
|
|
40
|
+
readonly observedAt: string;
|
|
41
|
+
} | {
|
|
42
|
+
readonly status: "found";
|
|
43
|
+
readonly source: "snapshot";
|
|
44
|
+
readonly observation: {
|
|
45
|
+
readonly company: {
|
|
46
|
+
readonly raw?: Readonly<Record<string, unknown>>;
|
|
47
|
+
readonly ticker: string;
|
|
48
|
+
readonly sourceId: string;
|
|
49
|
+
readonly companyName: string;
|
|
50
|
+
readonly address: string | null;
|
|
51
|
+
readonly tel: string | null;
|
|
52
|
+
readonly fax: string | null;
|
|
53
|
+
readonly website: string | null;
|
|
54
|
+
readonly description: string | null;
|
|
55
|
+
readonly issueType: string | null;
|
|
56
|
+
readonly foreignExempt: boolean | null;
|
|
57
|
+
readonly people: ReadonlyArray<{
|
|
58
|
+
readonly sourceName: string;
|
|
59
|
+
readonly displayName: string;
|
|
60
|
+
readonly title: string;
|
|
61
|
+
readonly sourceCategory: "directors" | "secretaries";
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
64
|
+
readonly startedAt: string;
|
|
65
|
+
readonly observedAt: string;
|
|
66
|
+
readonly lastChangedAt: string;
|
|
67
|
+
};
|
|
68
|
+
readonly generation: string;
|
|
69
|
+
readonly enrichment?: {
|
|
70
|
+
readonly ticker: string;
|
|
71
|
+
readonly sourceId: string;
|
|
72
|
+
readonly abn: string;
|
|
73
|
+
readonly acn: string | null;
|
|
74
|
+
readonly legalName: string;
|
|
75
|
+
readonly identityEvidence: {
|
|
76
|
+
readonly url: string;
|
|
77
|
+
readonly sha256: string;
|
|
78
|
+
readonly observedAt: string;
|
|
79
|
+
readonly kind: "issuer-filing" | "issuer-website";
|
|
80
|
+
};
|
|
81
|
+
readonly abrEvidence: {
|
|
82
|
+
readonly source: "abr-live" | "abr-snapshot";
|
|
83
|
+
readonly observedAt: string;
|
|
84
|
+
};
|
|
85
|
+
readonly website: {
|
|
86
|
+
readonly url: string;
|
|
87
|
+
readonly evidence: {
|
|
88
|
+
readonly url: string;
|
|
89
|
+
readonly sha256: string;
|
|
90
|
+
readonly observedAt: string;
|
|
91
|
+
readonly kind: "issuer-filing" | "issuer-website";
|
|
92
|
+
};
|
|
93
|
+
} | null;
|
|
94
|
+
};
|
|
95
|
+
readonly requiresRecheck: boolean;
|
|
96
|
+
readonly fallbackReason?: "INVALID_INPUT" | "PARSE_FAILED" | "SOURCE_MISMATCH" | "TRANSPORT_FAILED" | "TIMEOUT" | "RATE_LIMITED" | "UPSTREAM_UNAVAILABLE" | "CONFIGURATION_ERROR" | "SNAPSHOT_UNAVAILABLE" | "CANCELLED" | "INTERNAL_ERROR" | "ACCESS_DENIED" | "UPSTREAM_REJECTED" | "PERSISTENCE_FAILED" | "PUBLICATION_CONFLICT" | "COVERAGE_REJECTED";
|
|
97
|
+
} | {
|
|
98
|
+
readonly status: "missing";
|
|
99
|
+
readonly source: "snapshot";
|
|
100
|
+
readonly generation: string;
|
|
101
|
+
};
|
|
102
|
+
/** The body of suggest. */
|
|
103
|
+
export type SuggestRequest = {
|
|
104
|
+
/** live: ASX now; snapshot: the stored directory; live-first: ASX, then the snapshot on failure. */
|
|
105
|
+
readonly mode?: "live" | "snapshot" | "live-first";
|
|
106
|
+
/** A ticker or company name prefix. */
|
|
107
|
+
readonly query: string;
|
|
108
|
+
/** How many suggestions to return, 1 to 10. */
|
|
109
|
+
readonly limit?: number;
|
|
110
|
+
};
|
|
111
|
+
/** The reply of suggest. */
|
|
112
|
+
export type SuggestResponse = {
|
|
113
|
+
readonly source: "live";
|
|
114
|
+
readonly directoryObservedAt: string;
|
|
115
|
+
readonly suggestions: ReadonlyArray<{
|
|
116
|
+
readonly ticker: string;
|
|
117
|
+
readonly companyName: string;
|
|
118
|
+
readonly industryGroup: string | null;
|
|
119
|
+
readonly listingDate: string | null;
|
|
120
|
+
readonly marketCapitalisation: number | "Infinity" | "-Infinity" | "NaN" | null;
|
|
121
|
+
}>;
|
|
122
|
+
} | {
|
|
123
|
+
readonly source: "snapshot";
|
|
124
|
+
readonly generation: string;
|
|
125
|
+
readonly directoryObservedAt: string;
|
|
126
|
+
readonly requiresRecheck: boolean;
|
|
127
|
+
readonly suggestions: ReadonlyArray<{
|
|
128
|
+
readonly ticker: string;
|
|
129
|
+
readonly companyName: string;
|
|
130
|
+
readonly industryGroup: string | null;
|
|
131
|
+
readonly listingDate: string | null;
|
|
132
|
+
readonly marketCapitalisation: number | "Infinity" | "-Infinity" | "NaN" | null;
|
|
133
|
+
}>;
|
|
134
|
+
readonly fallbackReason?: "INVALID_INPUT" | "PARSE_FAILED" | "SOURCE_MISMATCH" | "TRANSPORT_FAILED" | "TIMEOUT" | "RATE_LIMITED" | "UPSTREAM_UNAVAILABLE" | "CONFIGURATION_ERROR" | "SNAPSHOT_UNAVAILABLE" | "CANCELLED" | "INTERNAL_ERROR" | "ACCESS_DENIED" | "UPSTREAM_REJECTED" | "PERSISTENCE_FAILED" | "PUBLICATION_CONFLICT" | "COVERAGE_REJECTED";
|
|
135
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { AsxClient } from "./generated/client.js";
|
|
2
|
+
export { errorCodes, operations } from "./generated/operations.js";
|
|
3
|
+
export type { OperationName } from "./generated/operations.js";
|
|
4
|
+
export type { LookupRequest, LookupResponse, SuggestRequest, SuggestResponse } from "./generated/types.js";
|
|
5
|
+
export { CommsIdError, isCommsIdError, outcomeOf } from "./runtime/errors.js";
|
|
6
|
+
export type { Outcome } from "./runtime/errors.js";
|
|
7
|
+
export type { CallOptions } from "./runtime/http.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Generated by @comms-id/forge-transformers from the service's OpenAPI contract. Do not edit: change the contract and regenerate.
|
|
2
|
+
// Browser-safe entry: types, errors and the operation table. No signing code is imported here.
|
|
3
|
+
export { errorCodes, operations } from "./generated/operations.js";
|
|
4
|
+
export { CommsIdError, isCommsIdError, outcomeOf } from "./runtime/errors.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Environment } from "./environment.js";
|
|
2
|
+
import type { TransportConfig } from "./http.js";
|
|
3
|
+
interface Common {
|
|
4
|
+
readonly deadlineMs?: number;
|
|
5
|
+
readonly fetch?: typeof fetch;
|
|
6
|
+
readonly maxRetries?: number;
|
|
7
|
+
}
|
|
8
|
+
/** Calls the consumer's own server route, which signs for the app (the relay). */
|
|
9
|
+
export interface RelayOptions extends Common {
|
|
10
|
+
/** For example "/api/comms-id/address". The operation name is appended. */
|
|
11
|
+
readonly relayUrl: string;
|
|
12
|
+
}
|
|
13
|
+
/** Calls the hosted service with a short-lived capability that the caller supplies. */
|
|
14
|
+
export interface CapabilityOptions extends Common {
|
|
15
|
+
/** Another host. It wins over `environment`. */
|
|
16
|
+
readonly baseUrl?: string;
|
|
17
|
+
/** "live" (the default) or "test". */
|
|
18
|
+
readonly environment?: Environment;
|
|
19
|
+
readonly getCapability: () => Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
export type BrowserOptions = RelayOptions | CapabilityOptions;
|
|
22
|
+
export declare const createBrowserTransport: (options: BrowserOptions) => TransportConfig;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Runtime template: copied into every generated client package as src/runtime/browser-core.ts.
|
|
2
|
+
// Browser entry: it never signs and never holds a secret. It either calls the consumer's own
|
|
3
|
+
// relay route, or sends a short-lived capability that the caller obtains by other means.
|
|
4
|
+
import { resolveBaseUrl } from "./environment.js";
|
|
5
|
+
import { CommsIdError } from "./errors.js";
|
|
6
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
7
|
+
const passThrough = (options) => ({
|
|
8
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
9
|
+
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
|
|
10
|
+
...(options.maxRetries === undefined ? {} : { maxRetries: options.maxRetries }),
|
|
11
|
+
});
|
|
12
|
+
/** A browser client with neither a relay nor a capability source has no identified caller. */
|
|
13
|
+
const requireCaller = (options) => {
|
|
14
|
+
const relay = options.relayUrl;
|
|
15
|
+
const capability = options.getCapability;
|
|
16
|
+
if ((typeof relay !== "string" || relay === "") && typeof capability !== "function") {
|
|
17
|
+
throw new CommsIdError({
|
|
18
|
+
code: "NOT_CONFIGURED",
|
|
19
|
+
message: "Set relayUrl (your server route) or getCapability: the browser holds no credential of its own.",
|
|
20
|
+
status: 0,
|
|
21
|
+
retryable: false,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
export const createBrowserTransport = (options) => {
|
|
26
|
+
requireCaller(options);
|
|
27
|
+
if ("relayUrl" in options) {
|
|
28
|
+
const relay = options.relayUrl.replace(TRAILING_SLASHES, "");
|
|
29
|
+
return {
|
|
30
|
+
urlFor: (operation) => `${relay}/${operation.name}`,
|
|
31
|
+
headers: () => Promise.resolve({}),
|
|
32
|
+
...passThrough(options),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const base = resolveBaseUrl(options.environment, options.baseUrl).replace(TRAILING_SLASHES, "");
|
|
36
|
+
return {
|
|
37
|
+
urlFor: (operation) => `${base}${operation.path}`,
|
|
38
|
+
headers: async () => ({ authorization: `Bearer ${await options.getCapability()}` }),
|
|
39
|
+
...passThrough(options),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type Environment = "live" | "test";
|
|
2
|
+
export declare const BASE_URLS: Readonly<Record<Environment, string>>;
|
|
3
|
+
export declare const isEnvironment: (value: unknown) => value is Environment;
|
|
4
|
+
/** The base URL: an explicit one wins, then the environment (live by default). */
|
|
5
|
+
export declare const resolveBaseUrl: (environment: unknown, baseUrl: string | undefined) => string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Runtime template: copied into every generated client package as src/runtime/environment.ts.
|
|
2
|
+
// The two environments of the hosted API (platform #515, T4). They differ in the base URL and in
|
|
3
|
+
// the keys the service knows: TEST answers with synthetic replies for apps registered in the
|
|
4
|
+
// development directory; LIVE answers with real data for apps registered in production. The API,
|
|
5
|
+
// the errors and the authentication are the same, so going live changes only the environment and
|
|
6
|
+
// the key.
|
|
7
|
+
import { CommsIdError } from "./errors.js";
|
|
8
|
+
export const BASE_URLS = {
|
|
9
|
+
live: "https://api.comms.id",
|
|
10
|
+
test: "https://api-test.comms.id",
|
|
11
|
+
};
|
|
12
|
+
export const isEnvironment = (value) => value === "live" || value === "test";
|
|
13
|
+
/** The base URL: an explicit one wins, then the environment (live by default). */
|
|
14
|
+
export const resolveBaseUrl = (environment, baseUrl) => {
|
|
15
|
+
if (baseUrl !== undefined) {
|
|
16
|
+
return baseUrl;
|
|
17
|
+
}
|
|
18
|
+
if (environment === undefined) {
|
|
19
|
+
return BASE_URLS.live;
|
|
20
|
+
}
|
|
21
|
+
if (!isEnvironment(environment)) {
|
|
22
|
+
throw new CommsIdError({
|
|
23
|
+
code: "NOT_CONFIGURED",
|
|
24
|
+
message: 'environment must be "test" or "live".',
|
|
25
|
+
status: 0,
|
|
26
|
+
retryable: false,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return BASE_URLS[environment];
|
|
30
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The three results a lookup can have besides a match. */
|
|
2
|
+
export type Outcome = "no-match" | "source-unavailable" | "failed";
|
|
3
|
+
export interface ErrorInit {
|
|
4
|
+
/** The reply's own body, when the service answered an error status with a structured reply instead of an error body. */
|
|
5
|
+
readonly body?: unknown;
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly message: string;
|
|
8
|
+
readonly requestId?: string | undefined;
|
|
9
|
+
readonly retryable: boolean;
|
|
10
|
+
/** The HTTP status, or 0 when no reply was received or the call never left the client. */
|
|
11
|
+
readonly status: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Every failure of a call. The service sends `code`, `message`, `retryable` and `requestId`.
|
|
15
|
+
* The client adds the codes INVALID_REQUEST (refused before any request), NETWORK, TIMEOUT,
|
|
16
|
+
* ABORTED and INVALID_RESPONSE.
|
|
17
|
+
*/
|
|
18
|
+
export declare class CommsIdError extends Error {
|
|
19
|
+
readonly code: string;
|
|
20
|
+
readonly status: number;
|
|
21
|
+
readonly retryable: boolean;
|
|
22
|
+
readonly requestId: string | undefined;
|
|
23
|
+
/** The structured reply of a status such as 503 whose body is an outcome, not an error body (code HTTP_<status>). */
|
|
24
|
+
readonly body: unknown;
|
|
25
|
+
constructor(init: ErrorInit);
|
|
26
|
+
}
|
|
27
|
+
export declare const isCommsIdError: (value: unknown) => value is CommsIdError;
|
|
28
|
+
/** Maps an error to the outcome that the reply convention names. A match never reaches this. */
|
|
29
|
+
export declare const outcomeOf: (error: unknown) => Outcome;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Runtime template: copied into every generated client package as src/runtime/errors.ts.
|
|
2
|
+
// It has no imports, so the copy is identical to this file.
|
|
3
|
+
/**
|
|
4
|
+
* Every failure of a call. The service sends `code`, `message`, `retryable` and `requestId`.
|
|
5
|
+
* The client adds the codes INVALID_REQUEST (refused before any request), NETWORK, TIMEOUT,
|
|
6
|
+
* ABORTED and INVALID_RESPONSE.
|
|
7
|
+
*/
|
|
8
|
+
export class CommsIdError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
status;
|
|
11
|
+
retryable;
|
|
12
|
+
requestId;
|
|
13
|
+
/** The structured reply of a status such as 503 whose body is an outcome, not an error body (code HTTP_<status>). */
|
|
14
|
+
body;
|
|
15
|
+
constructor(init) {
|
|
16
|
+
super(init.message);
|
|
17
|
+
this.name = "CommsIdError";
|
|
18
|
+
this.code = init.code;
|
|
19
|
+
this.status = init.status;
|
|
20
|
+
this.retryable = init.retryable;
|
|
21
|
+
this.requestId = init.requestId;
|
|
22
|
+
this.body = init.body;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export const isCommsIdError = (value) => value instanceof CommsIdError;
|
|
26
|
+
/** HTTP statuses that say the source or the path to it is down: 502, 503 and 504. The client's own failures have status 0. */
|
|
27
|
+
const GATEWAY_STATUSES = new Set([502, 503, 504]);
|
|
28
|
+
/** Maps an error to the outcome that the reply convention names. A match never reaches this. */
|
|
29
|
+
export const outcomeOf = (error) => {
|
|
30
|
+
if (!isCommsIdError(error)) {
|
|
31
|
+
return "failed";
|
|
32
|
+
}
|
|
33
|
+
if (error.code === "NOT_FOUND") {
|
|
34
|
+
return "no-match";
|
|
35
|
+
}
|
|
36
|
+
if (error.code === "UNAVAILABLE" || GATEWAY_STATUSES.has(error.status)) {
|
|
37
|
+
return "source-unavailable";
|
|
38
|
+
}
|
|
39
|
+
return "failed";
|
|
40
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TransportConfig } from "./http.js";
|
|
2
|
+
/** Every fixture reply carries this header, and every fixture client exposes it. */
|
|
3
|
+
export declare const FIXTURE_HEADER = "x-comms-id-fixture";
|
|
4
|
+
export declare const FIXTURE_NOTICE = "LOCAL FIXTURE: fake data, no network call was made.";
|
|
5
|
+
/** A fetch that answers each operation path with its canned reply. */
|
|
6
|
+
export declare const createFixtureFetch: (replies: Readonly<Record<string, unknown>>) => typeof fetch;
|
|
7
|
+
export declare const createFixtureTransport: (replies: Readonly<Record<string, unknown>>) => TransportConfig;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Runtime template: copied into every generated client package as src/runtime/fixture-core.ts.
|
|
2
|
+
// LOCAL FIXTURES: no network. The client runs the same request and reply checks as a real call.
|
|
3
|
+
/** Every fixture reply carries this header, and every fixture client exposes it. */
|
|
4
|
+
export const FIXTURE_HEADER = "x-comms-id-fixture";
|
|
5
|
+
export const FIXTURE_NOTICE = "LOCAL FIXTURE: fake data, no network call was made.";
|
|
6
|
+
/** A fetch that answers each operation path with its canned reply. */
|
|
7
|
+
export const createFixtureFetch = (replies) => (input) => {
|
|
8
|
+
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url, "http://fixture.invalid");
|
|
9
|
+
const reply = replies[url.pathname];
|
|
10
|
+
if (reply === undefined) {
|
|
11
|
+
return Promise.resolve(new Response(JSON.stringify({ code: "NOT_FOUND", message: "No fixture for this operation.", retryable: false }), {
|
|
12
|
+
status: 404,
|
|
13
|
+
headers: { "content-type": "application/json", [FIXTURE_HEADER]: FIXTURE_NOTICE },
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
return Promise.resolve(new Response(JSON.stringify(reply), {
|
|
17
|
+
status: 200,
|
|
18
|
+
headers: { "content-type": "application/json", [FIXTURE_HEADER]: FIXTURE_NOTICE },
|
|
19
|
+
}));
|
|
20
|
+
};
|
|
21
|
+
export const createFixtureTransport = (replies) => ({
|
|
22
|
+
urlFor: (operation) => operation.path,
|
|
23
|
+
headers: () => Promise.resolve({}),
|
|
24
|
+
fetch: createFixtureFetch(replies),
|
|
25
|
+
maxRetries: 0,
|
|
26
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Schema } from "./validate.js";
|
|
2
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
3
|
+
/** A hand-written check that a schema cannot state. It returns the reason to refuse, or undefined. */
|
|
4
|
+
export type Guard = (input: unknown) => string | undefined;
|
|
5
|
+
/** One hosted operation, as the generator writes it from the OpenAPI document. */
|
|
6
|
+
export interface Operation {
|
|
7
|
+
/** Run after the schema check and before any request (for example an ABN check digit). */
|
|
8
|
+
readonly guard?: Guard | undefined;
|
|
9
|
+
readonly method: HttpMethod;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly request: Schema | undefined;
|
|
13
|
+
readonly response: Schema;
|
|
14
|
+
/** From x-comms-id-safe-to-retry: only such an operation is ever sent twice. */
|
|
15
|
+
readonly safeToRetry: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface CallOptions {
|
|
18
|
+
/** Overrides the deadline for this call, in milliseconds. */
|
|
19
|
+
readonly deadlineMs?: number;
|
|
20
|
+
/** Cancels the call. The error code is ABORTED. */
|
|
21
|
+
readonly signal?: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
export interface TransportConfig {
|
|
24
|
+
/** The deadline for the whole call, retries included. 10 seconds by default. */
|
|
25
|
+
readonly deadlineMs?: number;
|
|
26
|
+
readonly fetch?: typeof fetch;
|
|
27
|
+
readonly headers: () => Promise<Readonly<Record<string, string>>>;
|
|
28
|
+
/** Extra tries of a safe operation after a retryable failure. 2 by default. */
|
|
29
|
+
readonly maxRetries?: number;
|
|
30
|
+
readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
31
|
+
readonly urlFor: (operation: Operation) => string;
|
|
32
|
+
}
|
|
33
|
+
/** Sends one operation and returns its decoded reply, or throws a CommsIdError. */
|
|
34
|
+
export declare const call: <Reply>(transport: TransportConfig, operation: Operation, input: unknown, options?: CallOptions) => Promise<Reply>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Runtime template: copied into every generated client package as src/runtime/http.ts.
|
|
2
|
+
// One call: validate the request, send it with a deadline and a cancel signal, retry only an
|
|
3
|
+
// operation that is safe to repeat, validate the reply. No dependencies, no Comms.ID logic
|
|
4
|
+
// beyond the reply convention.
|
|
5
|
+
import { CommsIdError } from "./errors.js";
|
|
6
|
+
import { validate } from "./validate.js";
|
|
7
|
+
const DEFAULT_DEADLINE_MS = 10_000;
|
|
8
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
9
|
+
const BASE_DELAY_MS = 200;
|
|
10
|
+
const MAX_DELAY_MS = 2000;
|
|
11
|
+
const fail = (code, message, status, retryable, requestId, body) => new CommsIdError({ code, message, status, retryable, requestId, body });
|
|
12
|
+
const summarize = (issues) => issues
|
|
13
|
+
.slice(0, 3)
|
|
14
|
+
.map((issue) => `${issue.path} ${issue.message}`)
|
|
15
|
+
.join("; ");
|
|
16
|
+
const defaultSleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
17
|
+
const timer = setTimeout(resolve, ms);
|
|
18
|
+
signal.addEventListener("abort", () => {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
reject(signal.reason);
|
|
21
|
+
}, { once: true });
|
|
22
|
+
});
|
|
23
|
+
const abortError = (user) => user?.aborted === true
|
|
24
|
+
? fail("ABORTED", "The call was cancelled.", 0, false)
|
|
25
|
+
: fail("TIMEOUT", "The call passed its deadline.", 0, true);
|
|
26
|
+
const retryAfterMs = (response) => {
|
|
27
|
+
const header = response.headers.get("retry-after");
|
|
28
|
+
const seconds = header === null ? Number.NaN : Number(header);
|
|
29
|
+
return Number.isInteger(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
|
|
30
|
+
};
|
|
31
|
+
const readFailure = async (response) => {
|
|
32
|
+
const headerId = response.headers.get("x-request-id") ?? undefined;
|
|
33
|
+
let body;
|
|
34
|
+
try {
|
|
35
|
+
body = await response.json();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
body = undefined;
|
|
39
|
+
}
|
|
40
|
+
const record = typeof body === "object" && body !== null ? body : {};
|
|
41
|
+
if (typeof record.code === "string" && typeof record.message === "string" && typeof record.retryable === "boolean") {
|
|
42
|
+
const requestId = typeof record.requestId === "string" ? record.requestId : headerId;
|
|
43
|
+
return fail(record.code, record.message, response.status, record.retryable, requestId);
|
|
44
|
+
}
|
|
45
|
+
const gateway = response.status >= 502 && response.status <= 504;
|
|
46
|
+
return fail(`HTTP_${response.status}`, `The service answered ${response.status}.`, response.status, gateway, headerId,
|
|
47
|
+
// A structured reply with an error status (an unavailable or partial outcome) keeps its body.
|
|
48
|
+
Object.keys(record).length > 0 ? record : undefined);
|
|
49
|
+
};
|
|
50
|
+
const readSuccess = async (operation, response) => {
|
|
51
|
+
const requestId = response.headers.get("x-request-id") ?? undefined;
|
|
52
|
+
let value;
|
|
53
|
+
try {
|
|
54
|
+
value = await response.json();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: fail("INVALID_RESPONSE", "The reply is not JSON.", response.status, false, requestId),
|
|
60
|
+
retryAfterMs: undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const issues = validate(operation.response, value, "response");
|
|
64
|
+
if (issues.length > 0) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: fail("INVALID_RESPONSE", `The reply does not match the contract: ${summarize(issues)}`, response.status, false, requestId),
|
|
68
|
+
retryAfterMs: undefined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, value };
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Builds the credential headers. A failure here is not a network failure: a CommsIdError (for
|
|
75
|
+
* example a refused public key) is passed on as it is, and anything else is a configuration
|
|
76
|
+
* error. Neither is sent anywhere.
|
|
77
|
+
*/
|
|
78
|
+
const credentialHeaders = async (transport) => {
|
|
79
|
+
try {
|
|
80
|
+
return { ok: true, headers: await transport.headers() };
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
if (cause instanceof CommsIdError) {
|
|
84
|
+
return { ok: false, error: cause };
|
|
85
|
+
}
|
|
86
|
+
const reason = cause instanceof Error ? cause.message : "unknown error";
|
|
87
|
+
return { ok: false, error: fail("NOT_CONFIGURED", `Could not prepare the credentials: ${reason}`, 0, false) };
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const attemptOnce = async (transport, operation, body, signal, user) => {
|
|
91
|
+
if (signal.aborted) {
|
|
92
|
+
throw abortError(user);
|
|
93
|
+
}
|
|
94
|
+
const credentials = await credentialHeaders(transport);
|
|
95
|
+
// A cancel that arrives while the credentials are built must not reach fetch.
|
|
96
|
+
if (signal.aborted) {
|
|
97
|
+
throw abortError(user);
|
|
98
|
+
}
|
|
99
|
+
if (!credentials.ok) {
|
|
100
|
+
return { ok: false, error: credentials.error, retryAfterMs: undefined };
|
|
101
|
+
}
|
|
102
|
+
const headers = {
|
|
103
|
+
accept: "application/json",
|
|
104
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
105
|
+
...credentials.headers,
|
|
106
|
+
};
|
|
107
|
+
let response;
|
|
108
|
+
try {
|
|
109
|
+
response = await (transport.fetch ?? fetch)(transport.urlFor(operation), {
|
|
110
|
+
method: operation.method,
|
|
111
|
+
headers,
|
|
112
|
+
body,
|
|
113
|
+
signal,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (cause) {
|
|
117
|
+
if (signal.aborted) {
|
|
118
|
+
throw abortError(user);
|
|
119
|
+
}
|
|
120
|
+
const reason = cause instanceof Error ? cause.message : "The request failed.";
|
|
121
|
+
return { ok: false, error: fail("NETWORK", reason, 0, true), retryAfterMs: undefined };
|
|
122
|
+
}
|
|
123
|
+
if (response.ok) {
|
|
124
|
+
return await readSuccess(operation, response);
|
|
125
|
+
}
|
|
126
|
+
return { ok: false, error: await readFailure(response), retryAfterMs: retryAfterMs(response) };
|
|
127
|
+
};
|
|
128
|
+
const prepare = (operation, input) => {
|
|
129
|
+
if (operation.request === undefined) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const issues = validate(operation.request, input, "request");
|
|
133
|
+
if (issues.length > 0) {
|
|
134
|
+
throw fail("INVALID_REQUEST", `The request is not valid: ${summarize(issues)}`, 0, false);
|
|
135
|
+
}
|
|
136
|
+
const refusal = operation.guard?.(input);
|
|
137
|
+
if (refusal !== undefined) {
|
|
138
|
+
throw fail("INVALID_REQUEST", `The request is not valid: ${refusal}`, 0, false);
|
|
139
|
+
}
|
|
140
|
+
return JSON.stringify(input);
|
|
141
|
+
};
|
|
142
|
+
/** Sends one operation and returns its decoded reply, or throws a CommsIdError. */
|
|
143
|
+
export const call = async (transport, operation, input, options = {}) => {
|
|
144
|
+
const body = prepare(operation, input);
|
|
145
|
+
const deadline = AbortSignal.timeout(options.deadlineMs ?? transport.deadlineMs ?? DEFAULT_DEADLINE_MS);
|
|
146
|
+
const signal = options.signal === undefined ? deadline : AbortSignal.any([options.signal, deadline]);
|
|
147
|
+
const maxRetries = operation.safeToRetry ? (transport.maxRetries ?? DEFAULT_MAX_RETRIES) : 0;
|
|
148
|
+
const sleep = transport.sleep ?? defaultSleep;
|
|
149
|
+
for (let attempt = 0;; attempt += 1) {
|
|
150
|
+
const result = await attemptOnce(transport, operation, body, signal, options.signal);
|
|
151
|
+
if (result.ok) {
|
|
152
|
+
return result.value;
|
|
153
|
+
}
|
|
154
|
+
if (attempt >= maxRetries || !result.error.retryable) {
|
|
155
|
+
throw result.error;
|
|
156
|
+
}
|
|
157
|
+
const delay = result.retryAfterMs ?? Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
|
|
158
|
+
try {
|
|
159
|
+
await sleep(delay, signal);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw abortError(options.signal);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Environment } from "./environment.js";
|
|
2
|
+
import type { TransportConfig } from "./http.js";
|
|
3
|
+
import { type PrivateJwk } from "./sign.js";
|
|
4
|
+
export declare const DEFAULT_BASE_URL: string;
|
|
5
|
+
export interface ServerOptions {
|
|
6
|
+
/** Another host. It wins over `environment`. */
|
|
7
|
+
readonly baseUrl?: string;
|
|
8
|
+
/** The registered app id (COMMS_ID_CLIENT_ID). */
|
|
9
|
+
readonly clientId: string;
|
|
10
|
+
readonly deadlineMs?: number;
|
|
11
|
+
/** "live" (the default) or "test": synthetic replies for apps registered in the development directory. */
|
|
12
|
+
readonly environment?: Environment;
|
|
13
|
+
readonly fetch?: typeof fetch;
|
|
14
|
+
readonly maxRetries?: number;
|
|
15
|
+
readonly now?: () => number;
|
|
16
|
+
/** The app's private Ed25519 key (COMMS_ID_PRIVATE_JWK). */
|
|
17
|
+
readonly privateJwk: PrivateJwk;
|
|
18
|
+
/** Seconds each signed token stays valid: 120 by default, 600 at most. */
|
|
19
|
+
readonly tokenLifetimeSeconds?: number;
|
|
20
|
+
}
|
|
21
|
+
/** What the service verifies: its audience and scope, written into the generated client. */
|
|
22
|
+
export interface ServerTarget {
|
|
23
|
+
readonly audience: string;
|
|
24
|
+
readonly scope: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const createServerTransport: (options: ServerOptions, target: ServerTarget) => TransportConfig;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Runtime template: copied into every generated client package as src/runtime/server-core.ts.
|
|
2
|
+
// Server entry only. The private key never leaves the consumer's server.
|
|
3
|
+
import { BASE_URLS, resolveBaseUrl } from "./environment.js";
|
|
4
|
+
import { CommsIdError } from "./errors.js";
|
|
5
|
+
import { createTokenSigner } from "./sign.js";
|
|
6
|
+
export const DEFAULT_BASE_URL = BASE_URLS.live;
|
|
7
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
8
|
+
/** A transport that signs a fresh short-lived token for every call. */
|
|
9
|
+
const notConfigured = (message) => new CommsIdError({ code: "NOT_CONFIGURED", message, status: 0, retryable: false });
|
|
10
|
+
/** A client with no identified caller is a configuration error: it never sends an unsigned request. */
|
|
11
|
+
const requireCaller = (options) => {
|
|
12
|
+
if (typeof options.clientId !== "string" || options.clientId.trim() === "") {
|
|
13
|
+
throw notConfigured("clientId is empty: set COMMS_ID_CLIENT_ID to your registered app id.");
|
|
14
|
+
}
|
|
15
|
+
const key = options.privateJwk;
|
|
16
|
+
if (key?.kty !== "OKP" || key.crv !== "Ed25519" || typeof key.x !== "string" || typeof key.d !== "string") {
|
|
17
|
+
throw notConfigured("privateJwk is missing or not an Ed25519 private key: set COMMS_ID_PRIVATE_JWK.");
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
export const createServerTransport = (options, target) => {
|
|
21
|
+
requireCaller(options);
|
|
22
|
+
const sign = createTokenSigner(options.privateJwk);
|
|
23
|
+
const base = resolveBaseUrl(options.environment, options.baseUrl).replace(TRAILING_SLASHES, "");
|
|
24
|
+
return {
|
|
25
|
+
urlFor: (operation) => `${base}${operation.path}`,
|
|
26
|
+
headers: async () => ({
|
|
27
|
+
authorization: `Bearer ${await sign({
|
|
28
|
+
clientId: options.clientId,
|
|
29
|
+
audience: target.audience,
|
|
30
|
+
scope: target.scope,
|
|
31
|
+
...(options.tokenLifetimeSeconds === undefined ? {} : { lifetimeSeconds: options.tokenLifetimeSeconds }),
|
|
32
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
33
|
+
})}`,
|
|
34
|
+
}),
|
|
35
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
36
|
+
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
|
|
37
|
+
...(options.maxRetries === undefined ? {} : { maxRetries: options.maxRetries }),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** An Ed25519 private key as a JWK: the key pair's private half stays on the consumer's server. */
|
|
2
|
+
export interface PrivateJwk {
|
|
3
|
+
readonly crv: "Ed25519";
|
|
4
|
+
readonly d: string;
|
|
5
|
+
readonly kty: "OKP";
|
|
6
|
+
readonly x: string;
|
|
7
|
+
}
|
|
8
|
+
export interface TokenInput {
|
|
9
|
+
/** The service the token is for. */
|
|
10
|
+
readonly audience: string;
|
|
11
|
+
/** The registered app id, sent as the token issuer. */
|
|
12
|
+
readonly clientId: string;
|
|
13
|
+
/** Seconds the token stays valid: 120 by default, 600 at most. */
|
|
14
|
+
readonly lifetimeSeconds?: number;
|
|
15
|
+
readonly now?: () => number;
|
|
16
|
+
readonly scope: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Returns a function that signs one short-lived token for each call. The key is imported once.
|
|
20
|
+
* The token is a JWT with alg EdDSA and the claims iss, aud, sub, jti, iat, exp and scope.
|
|
21
|
+
*/
|
|
22
|
+
export declare const createTokenSigner: (privateJwk: PrivateJwk) => ((input: TokenInput) => Promise<string>);
|