@solana-mobile/dapp-store-cli 0.16.0 → 0.16.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.
Files changed (113) hide show
  1. package/bin/dapp-store.js +3 -1
  2. package/lib/CliSetup.js +304 -505
  3. package/lib/CliUtils.js +6 -376
  4. package/lib/__tests__/CliSetupTest.js +484 -74
  5. package/lib/cli/__tests__/parseErrors.test.js +25 -0
  6. package/lib/cli/__tests__/signer.test.js +436 -0
  7. package/lib/cli/constants.js +23 -0
  8. package/lib/cli/messages.js +21 -0
  9. package/lib/cli/parseErrors.js +41 -0
  10. package/lib/{commands/publish/PublishCliSupport.js → cli/selfUpdate.js} +72 -38
  11. package/lib/{commands/publish/PublishCliRemove.js → cli/signer.js} +35 -56
  12. package/lib/index.js +96 -5
  13. package/lib/package.json +5 -24
  14. package/lib/portal/__tests__/releaseMetadata.test.js +647 -0
  15. package/lib/portal/__tests__/translators.test.js +76 -0
  16. package/lib/portal/__tests__/workflowClient.test.js +457 -0
  17. package/lib/portal/attestationClient.js +143 -0
  18. package/lib/portal/files.js +64 -0
  19. package/lib/portal/http.js +364 -0
  20. package/lib/portal/records.js +64 -0
  21. package/lib/portal/releaseMetadata.js +748 -0
  22. package/lib/portal/translators.js +460 -0
  23. package/lib/portal/types.js +1 -0
  24. package/lib/portal/workflowClient.js +704 -0
  25. package/lib/publication/PublicationProgressReporter.js +1051 -0
  26. package/lib/publication/__tests__/PublicationProgressReporter.test.js +174 -0
  27. package/lib/{commands/ValidateCommand.js → publication/__tests__/fundingPreflight.test.js} +90 -66
  28. package/lib/publication/__tests__/publicationSummary.test.js +26 -0
  29. package/lib/publication/cliValidation.js +482 -0
  30. package/lib/publication/fundingPreflight.js +246 -0
  31. package/lib/publication/publicationSummary.js +99 -0
  32. package/lib/{commands/utils.js → publication/runPublicationWorkflow.js} +16 -46
  33. package/package.json +5 -24
  34. package/src/CliSetup.ts +370 -505
  35. package/src/CliUtils.ts +9 -233
  36. package/src/__tests__/CliSetupTest.ts +272 -120
  37. package/src/cli/__tests__/parseErrors.test.ts +34 -0
  38. package/src/cli/__tests__/signer.test.ts +359 -0
  39. package/src/cli/constants.ts +3 -0
  40. package/src/cli/messages.ts +27 -0
  41. package/src/cli/parseErrors.ts +62 -0
  42. package/src/cli/selfUpdate.ts +59 -0
  43. package/src/cli/signer.ts +38 -0
  44. package/src/index.ts +31 -4
  45. package/src/portal/__tests__/releaseMetadata.test.ts +508 -0
  46. package/src/portal/__tests__/translators.test.ts +82 -0
  47. package/src/portal/__tests__/workflowClient.test.ts +278 -0
  48. package/src/portal/attestationClient.ts +19 -0
  49. package/src/portal/files.ts +73 -0
  50. package/src/portal/http.ts +170 -0
  51. package/src/portal/records.ts +38 -0
  52. package/src/portal/releaseMetadata.ts +489 -0
  53. package/src/portal/translators.ts +750 -0
  54. package/src/portal/types.ts +27 -0
  55. package/src/portal/workflowClient.ts +575 -0
  56. package/src/publication/PublicationProgressReporter.ts +1026 -0
  57. package/src/publication/__tests__/PublicationProgressReporter.test.ts +210 -0
  58. package/src/publication/__tests__/fundingPreflight.test.ts +78 -0
  59. package/src/publication/__tests__/publicationSummary.test.ts +30 -0
  60. package/src/publication/cliValidation.ts +264 -0
  61. package/src/publication/fundingPreflight.ts +123 -0
  62. package/src/publication/publicationSummary.ts +26 -0
  63. package/src/publication/runPublicationWorkflow.ts +46 -0
  64. package/lib/commands/create/CreateCliApp.js +0 -223
  65. package/lib/commands/create/CreateCliRelease.js +0 -290
  66. package/lib/commands/create/index.js +0 -40
  67. package/lib/commands/index.js +0 -3
  68. package/lib/commands/publish/PublishCliSubmit.js +0 -208
  69. package/lib/commands/publish/PublishCliUpdate.js +0 -211
  70. package/lib/commands/publish/index.js +0 -22
  71. package/lib/commands/scaffolding/ScaffoldInit.js +0 -15
  72. package/lib/commands/scaffolding/index.js +0 -1
  73. package/lib/config/EnvVariables.js +0 -59
  74. package/lib/config/PublishDetails.js +0 -915
  75. package/lib/config/S3StorageManager.js +0 -93
  76. package/lib/config/index.js +0 -2
  77. package/lib/generated/config_obj.json +0 -1
  78. package/lib/generated/config_schema.json +0 -1
  79. package/lib/prebuild_schema/publishing_source.yaml +0 -64
  80. package/lib/prebuild_schema/schemagen.js +0 -25
  81. package/lib/upload/CachedStorageDriver.js +0 -458
  82. package/lib/upload/TurboStorageDriver.js +0 -718
  83. package/lib/upload/__tests__/CachedStorageDriver.test.js +0 -437
  84. package/lib/upload/__tests__/TurboStorageDriver.test.js +0 -17
  85. package/lib/upload/__tests__/contentGateway.test.js +0 -17
  86. package/lib/upload/contentGateway.js +0 -23
  87. package/lib/upload/index.js +0 -2
  88. package/src/commands/ValidateCommand.ts +0 -82
  89. package/src/commands/create/CreateCliApp.ts +0 -93
  90. package/src/commands/create/CreateCliRelease.ts +0 -149
  91. package/src/commands/create/index.ts +0 -47
  92. package/src/commands/index.ts +0 -3
  93. package/src/commands/publish/PublishCliRemove.ts +0 -66
  94. package/src/commands/publish/PublishCliSubmit.ts +0 -93
  95. package/src/commands/publish/PublishCliSupport.ts +0 -66
  96. package/src/commands/publish/PublishCliUpdate.ts +0 -101
  97. package/src/commands/publish/index.ts +0 -29
  98. package/src/commands/scaffolding/ScaffoldInit.ts +0 -20
  99. package/src/commands/scaffolding/index.ts +0 -1
  100. package/src/commands/utils.ts +0 -33
  101. package/src/config/EnvVariables.ts +0 -39
  102. package/src/config/PublishDetails.ts +0 -456
  103. package/src/config/S3StorageManager.ts +0 -47
  104. package/src/config/index.ts +0 -2
  105. package/src/prebuild_schema/publishing_source.yaml +0 -64
  106. package/src/prebuild_schema/schemagen.js +0 -31
  107. package/src/upload/CachedStorageDriver.ts +0 -179
  108. package/src/upload/TurboStorageDriver.ts +0 -283
  109. package/src/upload/__tests__/CachedStorageDriver.test.ts +0 -246
  110. package/src/upload/__tests__/TurboStorageDriver.test.ts +0 -15
  111. package/src/upload/__tests__/contentGateway.test.ts +0 -31
  112. package/src/upload/contentGateway.ts +0 -37
  113. package/src/upload/index.ts +0 -2
@@ -0,0 +1,278 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, expect, jest, test } from "@jest/globals";
6
+
7
+ import { createPortalWorkflowClient } from "../workflowClient.js";
8
+
9
+ function createProcedureResponse(result: unknown): Response {
10
+ return new Response(JSON.stringify({ result: { data: result } }), {
11
+ status: 200,
12
+ headers: {
13
+ "content-type": "application/json",
14
+ },
15
+ });
16
+ }
17
+
18
+ let originalFetch: typeof fetch;
19
+ let fetchMock: jest.MockedFunction<typeof fetch>;
20
+ const tempDirs: string[] = [];
21
+
22
+ beforeEach(() => {
23
+ originalFetch = global.fetch;
24
+ fetchMock = jest.fn<typeof fetch>();
25
+ global.fetch = fetchMock as typeof fetch;
26
+ });
27
+
28
+ afterEach(() => {
29
+ global.fetch = originalFetch;
30
+ while (tempDirs.length > 0) {
31
+ fs.rmSync(tempDirs.pop()!, { recursive: true, force: true });
32
+ }
33
+ });
34
+
35
+ test("createIngestionSession maps apk-url sources to the portal externalUrl API shape", async () => {
36
+ fetchMock.mockResolvedValueOnce(
37
+ createProcedureResponse({
38
+ id: "ingestion-1",
39
+ dappId: "dapp-1",
40
+ idempotencyKey: "idem-1",
41
+ status: "Created",
42
+ sourceKind: "externalUrl",
43
+ sourceUrl: "https://downloads.example.com/releases/app-release.apk",
44
+ releaseFileName: "app-release.apk",
45
+ releaseFileSize: 10,
46
+ releaseId: "release-1",
47
+ publicationSessionId: "session-1",
48
+ bundle: {
49
+ release: {
50
+ id: "release-1",
51
+ releaseFileName: "app-release.apk",
52
+ versionCode: 42,
53
+ versionName: "42",
54
+ androidPackage: "com.example.app",
55
+ localizedName: "Example App",
56
+ newInVersion: "Fixes",
57
+ },
58
+ dapp: {
59
+ id: "dapp-1",
60
+ dappName: "Example App",
61
+ description: "A dApp",
62
+ walletAddress: "wallet-1",
63
+ androidPackage: "com.example.app",
64
+ },
65
+ publisher: {
66
+ id: "publisher-1",
67
+ type: "organization",
68
+ name: "Example Publisher",
69
+ website: "https://example.com",
70
+ email: "team@example.com",
71
+ },
72
+ installFile: {
73
+ uri: "https://downloads.example.com/releases/app-release.apk",
74
+ size: 10,
75
+ },
76
+ signerAuthority: {
77
+ dappWalletAddress: "wallet-1",
78
+ collectionAuthority: "wallet-1",
79
+ },
80
+ },
81
+ publicationSession: {
82
+ id: "session-1",
83
+ releaseId: "release-1",
84
+ stage: "PreparedForMint",
85
+ created: "2024-01-01T00:00:00.000Z",
86
+ updated: "2024-01-01T00:00:00.000Z",
87
+ },
88
+ })
89
+ );
90
+
91
+ const client = createPortalWorkflowClient({
92
+ apiBaseUrl: "https://portal.example.com/api",
93
+ apiKey: "portal-key",
94
+ });
95
+
96
+ const result = await client.createIngestionSession({
97
+ source: {
98
+ kind: "apk-url",
99
+ url: "https://downloads.example.com/releases/app-release.apk",
100
+ },
101
+ whatsNew: "Fixes",
102
+ idempotencyKey: "idem-1",
103
+ });
104
+
105
+ const [requestUrl, requestInit] = fetchMock.mock.calls[0]!;
106
+ const requestBody = JSON.parse(String(requestInit?.body));
107
+
108
+ expect(String(requestUrl)).toContain(
109
+ "/trpc/publication.createIngestionSession"
110
+ );
111
+ expect(requestInit?.method).toBe("POST");
112
+ expect(requestBody).toMatchObject({
113
+ source: {
114
+ kind: "externalUrl",
115
+ apkUrl: "https://downloads.example.com/releases/app-release.apk",
116
+ releaseFileName: "app-release.apk",
117
+ },
118
+ whatsNew: "Fixes",
119
+ idempotencyKey: "idem-1",
120
+ });
121
+ expect(result.source.kind).toBe("apk-url");
122
+ expect(result.bundle?.metadata?.installFile.origin).toBe("external");
123
+ expect(result.publicationSession?.checkpoint).toBe("bundle-ready");
124
+ });
125
+
126
+ test("createIngestionSession uploads local APK files before creating the ingestion session", async () => {
127
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dapp-store-apk-"));
128
+ const apkPath = path.join(tempDir, "release-build");
129
+ tempDirs.push(tempDir);
130
+ fs.writeFileSync(apkPath, Buffer.from("apk-binary"));
131
+
132
+ fetchMock
133
+ .mockResolvedValueOnce(
134
+ createProcedureResponse({
135
+ uploadUrl: "https://uploads.example.com/app.apk",
136
+ key: "upload-key",
137
+ providerId: "provider-1",
138
+ publicUrl: "https://cdn.example.com/app.apk",
139
+ })
140
+ )
141
+ .mockResolvedValueOnce(new Response("", { status: 200 }))
142
+ .mockResolvedValueOnce(
143
+ createProcedureResponse({
144
+ id: "ingestion-2",
145
+ dappId: "dapp-1",
146
+ idempotencyKey: "idem-2",
147
+ status: "Created",
148
+ sourceKind: "portalUpload",
149
+ sourceUrl: "https://cdn.example.com/app.apk",
150
+ releaseFileName: "release-build.apk",
151
+ releaseFileSize: 10,
152
+ releaseId: "release-2",
153
+ publicationSessionId: "session-2",
154
+ bundle: {
155
+ release: {
156
+ id: "release-2",
157
+ releaseFileName: "release-build.apk",
158
+ releaseFileUrl: "https://cdn.example.com/app.apk",
159
+ versionCode: 7,
160
+ versionName: "7",
161
+ androidPackage: "com.example.app",
162
+ localizedName: "Example App",
163
+ newInVersion: "Local upload",
164
+ },
165
+ dapp: {
166
+ id: "dapp-1",
167
+ dappName: "Example App",
168
+ description: "A dApp",
169
+ walletAddress: "wallet-1",
170
+ androidPackage: "com.example.app",
171
+ },
172
+ publisher: {
173
+ id: "publisher-1",
174
+ type: "organization",
175
+ name: "Example Publisher",
176
+ website: "https://example.com",
177
+ email: "team@example.com",
178
+ },
179
+ installFile: {
180
+ uri: "https://cdn.example.com/app.apk",
181
+ size: 10,
182
+ },
183
+ signerAuthority: {
184
+ dappWalletAddress: "wallet-1",
185
+ collectionAuthority: "wallet-1",
186
+ },
187
+ },
188
+ publicationSession: {
189
+ id: "session-2",
190
+ releaseId: "release-2",
191
+ stage: "PreparedForMint",
192
+ created: "2024-01-01T00:00:00.000Z",
193
+ updated: "2024-01-01T00:00:00.000Z",
194
+ },
195
+ })
196
+ );
197
+
198
+ const client = createPortalWorkflowClient({
199
+ apiBaseUrl: "https://portal.example.com/api",
200
+ apiKey: "portal-key",
201
+ });
202
+
203
+ const result = await client.createIngestionSession({
204
+ source: {
205
+ kind: "apk-file",
206
+ filePath: apkPath,
207
+ },
208
+ whatsNew: "Local upload",
209
+ idempotencyKey: "idem-2",
210
+ });
211
+
212
+ const [uploadTargetUrl, uploadTargetInit] = fetchMock.mock.calls[0]!;
213
+ const uploadTargetBody = JSON.parse(String(uploadTargetInit?.body));
214
+ expect(String(uploadTargetUrl)).toContain(
215
+ "/trpc/publication.createUploadTarget"
216
+ );
217
+ expect(uploadTargetBody).toMatchObject({
218
+ fileExtension: "apk",
219
+ contentType: "application/vnd.android.package-archive",
220
+ });
221
+
222
+ const [uploadUrl, uploadInit] = fetchMock.mock.calls[1]!;
223
+ expect(String(uploadUrl)).toBe("https://uploads.example.com/app.apk");
224
+ expect(uploadInit?.method).toBe("PUT");
225
+ expect(Buffer.from(uploadInit?.body as Uint8Array).toString()).toBe(
226
+ "apk-binary"
227
+ );
228
+
229
+ const [ingestionUrl, ingestionInit] = fetchMock.mock.calls[2]!;
230
+ const ingestionBody = JSON.parse(String(ingestionInit?.body));
231
+ expect(String(ingestionUrl)).toContain(
232
+ "/trpc/publication.createIngestionSession"
233
+ );
234
+ expect(ingestionBody).toMatchObject({
235
+ source: {
236
+ kind: "portalUpload",
237
+ releaseFileUrl: "https://cdn.example.com/app.apk",
238
+ releaseFileName: "release-build.apk",
239
+ releaseFileSize: 10,
240
+ },
241
+ whatsNew: "Local upload",
242
+ idempotencyKey: "idem-2",
243
+ });
244
+
245
+ expect(result.source.kind).toBe("apk-file");
246
+ expect(result.bundle?.metadata?.installFile.origin).toBe("portal");
247
+ expect(result.releaseId).toBe("release-2");
248
+ expect(result.publicationSessionId).toBe("session-2");
249
+ });
250
+
251
+ test("createIngestionSession explains local file permission errors", async () => {
252
+ const readFileSpy = jest.spyOn(fs, "readFileSync").mockImplementation(() => {
253
+ const error = Object.assign(new Error("operation not permitted"), {
254
+ code: "EPERM",
255
+ });
256
+ throw error;
257
+ });
258
+
259
+ const client = createPortalWorkflowClient({
260
+ apiBaseUrl: "https://portal.example.com/api",
261
+ apiKey: "portal-key",
262
+ });
263
+
264
+ await expect(
265
+ client.createIngestionSession({
266
+ source: {
267
+ kind: "apk-file",
268
+ filePath: "/Users/skumail/Downloads/app.apk",
269
+ },
270
+ whatsNew: "Local upload",
271
+ idempotencyKey: "idem-3",
272
+ })
273
+ ).rejects.toThrow(
274
+ "Move the APK out of Downloads into your workspace or another accessible folder"
275
+ );
276
+
277
+ readFileSpy.mockRestore();
278
+ });
@@ -0,0 +1,19 @@
1
+ import type { PublicationAttestationClient } from '@solana-mobile/dapp-store-publishing-tools';
2
+
3
+ import { callPortalProcedure } from './http.js';
4
+ import type { PortalClientConfig } from './types.js';
5
+
6
+ export function createPortalAttestationClient(
7
+ config: PortalClientConfig,
8
+ ): PublicationAttestationClient {
9
+ return {
10
+ async getBlockData() {
11
+ return await callPortalProcedure<{ slot: number; blockhash: string }>(
12
+ config,
13
+ 'attestation.getBlockData',
14
+ {},
15
+ 'query',
16
+ );
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,73 @@
1
+ export function ensureHttpsUrl(url: string): string {
2
+ const trimmed = url.trim();
3
+
4
+ if (!trimmed) {
5
+ return trimmed;
6
+ }
7
+
8
+ if (trimmed.startsWith("https://")) {
9
+ return trimmed;
10
+ }
11
+
12
+ if (trimmed.startsWith("http://")) {
13
+ return trimmed.replace(/^http:\/\//, "https://");
14
+ }
15
+
16
+ return `https://${trimmed}`;
17
+ }
18
+
19
+ export function inferFileNameFromUrl(url: string): string {
20
+ try {
21
+ const pathname = new URL(url).pathname;
22
+ const basename = pathname.split("/").filter(Boolean).pop();
23
+ return basename || "app-release.apk";
24
+ } catch {
25
+ return "app-release.apk";
26
+ }
27
+ }
28
+
29
+ export function inferFileExtension(fileName: string): string {
30
+ const extension = fileName.split(".").pop()?.trim().toLowerCase() || "apk";
31
+ return extension.replace(/[^a-z0-9]/g, "") || "apk";
32
+ }
33
+
34
+ export function ensureApkFileName(fileName: string): string {
35
+ return /\.apk$/i.test(fileName) ? fileName : `${fileName}.apk`;
36
+ }
37
+
38
+ export function inferMimeType(fileName: string): string {
39
+ const extension = inferFileExtension(fileName);
40
+ switch (extension) {
41
+ case "apk":
42
+ return "application/vnd.android.package-archive";
43
+ case "json":
44
+ return "application/json";
45
+ case "png":
46
+ return "image/png";
47
+ case "jpg":
48
+ case "jpeg":
49
+ return "image/jpeg";
50
+ case "webp":
51
+ return "image/webp";
52
+ case "gif":
53
+ return "image/gif";
54
+ case "svg":
55
+ return "image/svg+xml";
56
+ case "mp4":
57
+ return "video/mp4";
58
+ case "webm":
59
+ return "video/webm";
60
+ case "mov":
61
+ return "video/quicktime";
62
+ default:
63
+ return "application/octet-stream";
64
+ }
65
+ }
66
+
67
+ export function toBase64(buffer: Uint8Array): string {
68
+ return Buffer.from(buffer).toString("base64");
69
+ }
70
+
71
+ export function fromBase64(value: string): Uint8Array {
72
+ return new Uint8Array(Buffer.from(value, "base64"));
73
+ }
@@ -0,0 +1,170 @@
1
+ import {
2
+ type PortalClientConfig,
3
+ type PortalProcedureResult,
4
+ } from './types.js';
5
+ import { isRecord, readDeep } from './records.js';
6
+
7
+ function unwrapPortalResult<T>(
8
+ result: PortalProcedureResult<T> | Record<string, unknown> | T,
9
+ fallbackMessage: string,
10
+ ): T {
11
+ if (isRecord(result) && '_tag' in result) {
12
+ const tagged = result as PortalProcedureResult<T>;
13
+ if (tagged._tag === 'Left') {
14
+ throw new Error(tagged.left.message || fallbackMessage);
15
+ }
16
+
17
+ return tagged.right;
18
+ }
19
+
20
+ return result as T;
21
+ }
22
+
23
+ export async function callPortalProcedure<T>(
24
+ config: PortalClientConfig,
25
+ procedure: string,
26
+ input: unknown,
27
+ method: 'query' | 'mutation' = 'mutation',
28
+ ): Promise<T> {
29
+ const baseUrl = config.apiBaseUrl.replace(/\/$/, '');
30
+ const url = new URL(`${baseUrl}/trpc/${procedure}`);
31
+ const headers: Record<string, string> = {
32
+ accept: 'application/json',
33
+ 'x-api-key': config.apiKey,
34
+ };
35
+
36
+ let response: Response;
37
+ if (method === 'query') {
38
+ if (input !== undefined) {
39
+ url.searchParams.set('input', JSON.stringify(input));
40
+ }
41
+ response = await fetch(url, {
42
+ method: 'GET',
43
+ headers,
44
+ });
45
+ } else {
46
+ headers['content-type'] = 'application/json';
47
+ response = await fetch(url, {
48
+ method: 'POST',
49
+ headers,
50
+ body: input === undefined ? undefined : JSON.stringify(input),
51
+ });
52
+ }
53
+
54
+ const text = await response.text();
55
+ let payload: unknown;
56
+
57
+ try {
58
+ payload = text.length > 0 ? JSON.parse(text) : null;
59
+ } catch {
60
+ const preview =
61
+ text.replace(/\s+/g, ' ').trim().slice(0, 180) || '[empty]';
62
+ throw new Error(
63
+ `Failed to parse portal response from ${procedure}: ${preview}`,
64
+ );
65
+ }
66
+
67
+ const normalizedPayload =
68
+ isRecord(payload) && '0' in payload
69
+ ? (payload as Record<string, unknown>)['0']
70
+ : payload;
71
+
72
+ if (!response.ok) {
73
+ if (isRecord(normalizedPayload)) {
74
+ const error = readDeep(normalizedPayload, 'error.message');
75
+ if (typeof error === 'string' && error.length > 0) {
76
+ throw new Error(`${procedure}: ${error}`);
77
+ }
78
+
79
+ const nested = readDeep(normalizedPayload, 'result.data');
80
+ if (isRecord(nested) && nested._tag === 'Left') {
81
+ const left = nested as Extract<
82
+ PortalProcedureResult<T>,
83
+ { _tag: 'Left' }
84
+ >;
85
+ if (left.left.message) {
86
+ throw new Error(`${procedure}: ${left.left.message}`);
87
+ }
88
+ }
89
+ }
90
+
91
+ throw new Error(
92
+ `${procedure}: Portal request failed with status ${response.status}`,
93
+ );
94
+ }
95
+
96
+ const result =
97
+ readDeep(normalizedPayload, 'result.data') ??
98
+ readDeep(normalizedPayload, 'result');
99
+ return unwrapPortalResult(
100
+ result as PortalProcedureResult<T> | Record<string, unknown> | T,
101
+ `Portal request failed for ${procedure}`,
102
+ );
103
+ }
104
+
105
+ function isRetryableCreateIngestionSessionError(error: unknown): boolean {
106
+ const message =
107
+ error instanceof Error
108
+ ? error.message.toLowerCase()
109
+ : String(error).toLowerCase();
110
+
111
+ return (
112
+ message.includes(
113
+ 'failed to parse portal response from publication.createingestionsession',
114
+ ) ||
115
+ message.includes('gateway timeout') ||
116
+ message.includes('bad gateway') ||
117
+ message.includes('service unavailable') ||
118
+ message.includes('unexpected token <')
119
+ );
120
+ }
121
+
122
+ export async function callCreateIngestionSessionWithRetry(
123
+ config: PortalClientConfig,
124
+ input: Record<string, unknown>,
125
+ ): Promise<Record<string, unknown>> {
126
+ let lastError: unknown;
127
+
128
+ for (let attempt = 0; attempt < 3; attempt += 1) {
129
+ try {
130
+ return await callPortalProcedure<Record<string, unknown>>(
131
+ config,
132
+ 'publication.createIngestionSession',
133
+ input,
134
+ 'mutation',
135
+ );
136
+ } catch (error) {
137
+ lastError = error;
138
+ if (!isRetryableCreateIngestionSessionError(error) || attempt === 2) {
139
+ throw error;
140
+ }
141
+
142
+ await new Promise((resolve) => setTimeout(resolve, 1500 * (attempt + 1)));
143
+ }
144
+ }
145
+
146
+ throw lastError instanceof Error
147
+ ? lastError
148
+ : new Error('Failed to create ingestion session');
149
+ }
150
+
151
+ export async function uploadBytes(
152
+ uploadUrl: string,
153
+ body: Uint8Array,
154
+ contentType: string,
155
+ ): Promise<void> {
156
+ const response = await fetch(uploadUrl, {
157
+ method: 'PUT',
158
+ headers: {
159
+ 'content-type': contentType,
160
+ },
161
+ body,
162
+ });
163
+
164
+ if (!response.ok) {
165
+ const preview = (await response.text()).replace(/\s+/g, ' ').trim();
166
+ throw new Error(
167
+ `Failed to upload file to the portal: ${preview || response.statusText}`,
168
+ );
169
+ }
170
+ }
@@ -0,0 +1,38 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === 'object' && value !== null;
3
+ }
4
+
5
+ export function asRecord(
6
+ value: unknown,
7
+ ): Record<string, unknown> | undefined {
8
+ return isRecord(value) ? value : undefined;
9
+ }
10
+
11
+ export function readDeep(value: unknown, propertyPath: string): unknown {
12
+ const parts = propertyPath.split('.');
13
+ let current: unknown = value;
14
+
15
+ for (const part of parts) {
16
+ if (!isRecord(current)) {
17
+ return undefined;
18
+ }
19
+
20
+ current = current[part];
21
+ }
22
+
23
+ return current;
24
+ }
25
+
26
+ export function firstString(
27
+ value: unknown,
28
+ paths: string[],
29
+ ): string | undefined {
30
+ for (const propertyPath of paths) {
31
+ const candidate = readDeep(value, propertyPath);
32
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
33
+ return candidate;
34
+ }
35
+ }
36
+
37
+ return undefined;
38
+ }