pakstr 0.17.0 → 0.18.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.
@@ -184,9 +184,29 @@ build:
184
184
  builder: docker # Only "docker" is specified.
185
185
 
186
186
  publish:
187
+ upload:
188
+ provider: blossom
189
+ server: https://cdn.zapstore.dev
190
+
187
191
  zapstore:
188
- enabled: true # Default true when publish is present.
192
+ enabled: true # Set false to upload without Zapstore events.
193
+ source: upload
194
+ # source: https://downloads.example.com/my-app.apk # Or an absolute public APK URL.
195
+
196
+ relay: wss://relay.zapstore.dev
189
197
  # publishKey: PAKSTR_PUBLISH_NSEC # OPTIONAL. Omit to reuse ${nostr_1.PAKSTR_NSEC_ENV} for publishing.
198
+
199
+ # Gitea Release alternative: replace the active Blossom upload block above
200
+ # with this block. Keep the token itself in the environment, never this file.
201
+ # upload:
202
+ # provider: gitea-release
203
+ # baseUrl: https://git.example.com
204
+ # owner: your-org
205
+ # repo: your-app
206
+ # tokenEnv: PAKSTR_GITEA_TOKEN
207
+ # tag: "v{versionName}"
208
+ # assetName: "{appId}-{versionName}.apk"
209
+ # prerelease: false
190
210
  `;
191
211
  }
192
212
  function escapeYamlScalar(value) {
@@ -6,224 +6,265 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.publishCommand = publishCommand;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
- const pakstrConfig_1 = require("../core/pakstrConfig");
10
- const zapStore_1 = require("../core/zapStore");
9
+ const artifactUpload_1 = require("../core/artifactUpload");
11
10
  const blossom_1 = require("../core/blossom");
12
- const nostr_1 = require("../core/nostr");
11
+ const giteaRelease_1 = require("../core/giteaRelease");
13
12
  const identityProof_1 = require("../core/identityProof");
13
+ const nostr_1 = require("../core/nostr");
14
+ const pakstrConfig_1 = require("../core/pakstrConfig");
15
+ const zapStore_1 = require("../core/zapStore");
14
16
  const zapstoreConfig_1 = require("../core/zapstoreConfig");
15
- /**
16
- * `pakstr publish` — publish the NIP-82 application event, upload the signed APK
17
- * to Blossom, then publish the APK-dependent events. Spec §5.4 / §8.
18
- *
19
- * Requires that `pakstr build` + `pakstr sign` have already run (the signed
20
- * APK at build.out, plus its `.signer-sha256` sidecar).
21
- *
22
- * `--dry-run` skips network + disk: it builds the events with a stub blossom
23
- * descriptor and a mock relay transport, so you can preview exactly what would
24
- * be published without an APK or network.
25
- */
17
+ /** `pakstr publish` — optionally upload the signed APK and publish its Zapstore events. */
26
18
  async function publishCommand(configPath, options = {}) {
19
+ if (options.blossomFetch && options.fetchImpl) {
20
+ throw new Error("Specify only one of blossomFetch or fetchImpl");
21
+ }
27
22
  const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
28
23
  const apkPath = path_1.default.resolve(config.build.out);
29
- const zapstoreConfig = options.dryRun
30
- ? undefined
31
- : (0, zapstoreConfig_1.loadZapstorePublisherConfig)(config.configDir);
32
- const publishNsec = (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
33
- const pubkeyHex = await (0, nostr_1.getNpubHex)(publishNsec.bytes);
34
- const npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
35
- if (zapstoreConfig && zapstoreConfig.pubkeyHex !== pubkeyHex) {
36
- throw new Error(`Publisher npub mismatch: ${zapstoreConfig_1.ZAPSTORE_CONFIG_FILENAME} configures ${zapstoreConfig.pubkey}, but the active Pakstr signer is ${npub}`);
24
+ const uploadEnabled = config.publish.upload.enabled;
25
+ const zapstoreEnabled = config.publish.zapstoreEnabled;
26
+ const fetchImpl = options.fetchImpl ?? options.blossomFetch;
27
+ if (!uploadEnabled && !zapstoreEnabled) {
28
+ throw new Error("Nothing to publish: both publish.upload and publish.zapstore are disabled");
37
29
  }
38
30
  console.log("\n📤 pakstr publish" + (options.dryRun ? " (dry-run)" : ""));
39
31
  console.log("App:", config.app.appName, `(${config.app.appId})`);
40
- console.log("Publishing as:", publishNsec.envVar);
41
- console.log("Publisher npub:", npub);
42
- console.log("Relay:", config.publish.relay);
43
- console.log("Blossom:", config.publish.blossom);
44
- let blossomUrl;
45
- let apkSha256;
46
- let apkSize;
32
+ console.log("Upload:", uploadEnabled ? config.publish.upload.provider : "disabled");
33
+ console.log("Zapstore:", zapstoreEnabled ? `enabled (${config.publish.zapstoreSource})` : "disabled");
34
+ let artifact;
47
35
  let signerCertificateSha256 = "";
48
- let filename = "";
49
36
  let identityProof;
50
- const transport = options.transport ?? (options.dryRun ? new zapStore_1.MockRelayTransport() : new zapStore_1.WebsocketRelayTransport());
51
- const repository = zapstoreConfig?.repository ?? (0, zapstoreConfig_1.loadZapstoreRepository)(config.configDir);
52
- const app = {
53
- appId: config.app.appId,
54
- appName: zapstoreConfig?.name ?? config.app.appName,
55
- versionName: config.app.versionName,
56
- versionCode: config.app.versionCode,
57
- description: zapstoreConfig?.description ?? config.app.description,
58
- summary: zapstoreConfig?.summary,
59
- tags: zapstoreConfig?.tags,
60
- license: zapstoreConfig?.license,
61
- website: zapstoreConfig?.website,
62
- releaseNotes: zapstoreConfig?.releaseNotes,
63
- repository,
64
- };
65
- const target = { relayUrl: config.publish.relay };
66
- if (!options.dryRun) {
67
- if (!fs_1.default.existsSync(apkPath) || !fs_1.default.lstatSync(apkPath).isFile()) {
68
- throw new Error(`Signed APK not found at ${apkPath}. Run \`pakstr build\` and \`pakstr sign\` first.`);
69
- }
37
+ if (options.dryRun) {
38
+ artifact = {
39
+ filePath: apkPath,
40
+ filename: path_1.default.basename(apkPath),
41
+ contentType: "application/vnd.android.package-archive",
42
+ sha256: "0".repeat(64),
43
+ size: 0,
44
+ };
45
+ signerCertificateSha256 = "0".repeat(64);
46
+ }
47
+ else {
48
+ artifact = (0, artifactUpload_1.inspectArtifact)(apkPath);
70
49
  const certShaPath = `${apkPath}.signer-sha256`;
71
50
  if (!fs_1.default.existsSync(certShaPath)) {
72
51
  throw new Error(`Signer certificate sidecar not found: ${certShaPath}. Run \`pakstr sign\` first.`);
73
52
  }
74
53
  signerCertificateSha256 = fs_1.default.readFileSync(certShaPath, "utf8").trim();
75
- filename = path_1.default.basename(apkPath);
76
- // Read the NIP-C1 identity proof sidecar (optional; generated by
77
- // `pakstr sign` when a publish nsec is available).
78
54
  const proofPath = `${apkPath}.identity-proof`;
79
- if (fs_1.default.existsSync(proofPath)) {
80
- let raw;
81
- try {
82
- raw = fs_1.default.readFileSync(proofPath, "utf8").trim();
83
- }
84
- catch (e) {
85
- throw new Error(`Failed to read identity proof sidecar ${proofPath}: ${e instanceof Error ? e.message : String(e)}`);
86
- }
87
- identityProof = (0, identityProof_1.parseIdentityProofSidecar)(raw, proofPath, signerCertificateSha256);
55
+ if (zapstoreEnabled && fs_1.default.existsSync(proofPath)) {
56
+ identityProof = (0, identityProof_1.parseIdentityProofSidecar)(fs_1.default.readFileSync(proofPath, "utf8").trim(), proofPath, signerCertificateSha256);
88
57
  }
89
- else {
90
- // No sidecar found — warn so users can spot a missing 30509 proof
91
- // in CI output (the sign step may have run without a publish nsec).
92
- console.log("⚠️ No .identity-proof sidecar found — the kind 30509 identity proof will not be published.");
93
- console.log(" Re-run `pakstr sign` with a publish nsec available to generate it.");
58
+ else if (zapstoreEnabled) {
59
+ console.log("⚠️ No .identity-proof sidecar found — kind 30509 will not be published.");
94
60
  }
95
61
  }
96
- // Publish the application event first when Zapstore does not already have
97
- // the current metadata, preserving first-publish repository authorization.
98
- const appPublish = await (0, zapStore_1.buildAppMetadataEvent)({ app, publishNsec, target });
99
- const existingAppMetadata = options.dryRun
100
- ? undefined
101
- : await (0, zapStore_1.findAppMetadataEvent)(transport, {
62
+ const transport = options.transport ?? (options.dryRun ? new zapStore_1.MockRelayTransport() : new zapStore_1.WebsocketRelayTransport());
63
+ let zapstoreConfig;
64
+ let publishNsec;
65
+ let npub;
66
+ let pubkeyHex;
67
+ let appMetadata;
68
+ let app;
69
+ if (zapstoreEnabled) {
70
+ zapstoreConfig = options.dryRun ? undefined : (0, zapstoreConfig_1.loadZapstorePublisherConfig)(config.configDir);
71
+ publishNsec = (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
72
+ pubkeyHex = await (0, nostr_1.getNpubHex)(publishNsec.bytes);
73
+ npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
74
+ if (zapstoreConfig && zapstoreConfig.pubkeyHex !== pubkeyHex) {
75
+ throw new Error(`Publisher npub mismatch: ${zapstoreConfig_1.ZAPSTORE_CONFIG_FILENAME} configures ${zapstoreConfig.pubkey}, but the active Pakstr signer is ${npub}`);
76
+ }
77
+ console.log("Publishing as:", publishNsec.envVar);
78
+ console.log("Publisher npub:", npub);
79
+ console.log("Relay:", config.publish.relay);
80
+ app = {
81
+ appId: config.app.appId,
82
+ appName: zapstoreConfig?.name ?? config.app.appName,
83
+ versionName: config.app.versionName,
84
+ versionCode: config.app.versionCode,
85
+ description: zapstoreConfig?.description ?? config.app.description,
86
+ summary: zapstoreConfig?.summary,
87
+ tags: zapstoreConfig?.tags,
88
+ license: zapstoreConfig?.license,
89
+ website: zapstoreConfig?.website,
90
+ releaseNotes: zapstoreConfig?.releaseNotes,
91
+ repository: zapstoreConfig?.repository ?? (0, zapstoreConfig_1.loadZapstoreRepository)(config.configDir),
92
+ };
93
+ const expected = await (0, zapStore_1.buildAppMetadataEvent)({ app, publishNsec, target: { relayUrl: config.publish.relay } });
94
+ const existing = options.dryRun ? undefined : await (0, zapStore_1.findAppMetadataEvent)(transport, {
102
95
  appId: app.appId,
103
96
  pubkeyHex,
104
97
  relayUrl: config.publish.relay,
105
98
  });
106
- console.log(`📋 Existing kind-32267: ${existingAppMetadata?.id ?? "none"}`);
107
- const appMetadata = existingAppMetadata && (0, zapStore_1.isCurrentAppMetadataEvent)(existingAppMetadata, appPublish.appMetadata)
108
- ? existingAppMetadata
109
- : appPublish.appMetadata;
110
- if (appMetadata === appPublish.appMetadata) {
111
- console.log("📤 Publishing kind-32267...");
112
- await transport.publish(appMetadata, config.publish.relay);
113
- console.log(`✅ Published kind-32267: ${appMetadata.id}`);
99
+ console.log(`📋 Existing kind-32267: ${existing?.id ?? "none"}`);
100
+ appMetadata = existing && (0, zapStore_1.isCurrentAppMetadataEvent)(existing, expected.appMetadata)
101
+ ? existing
102
+ : expected.appMetadata;
103
+ if (appMetadata === expected.appMetadata) {
104
+ console.log("📤 Publishing kind-32267...");
105
+ await transport.publish(appMetadata, config.publish.relay);
106
+ console.log(`✅ Published kind-32267: ${appMetadata.id}`);
107
+ }
108
+ else {
109
+ console.log("✅ kind-32267 already up to date, skipping publish");
110
+ }
114
111
  }
115
- else {
116
- console.log("✅ kind-32267 already up to date, skipping publish");
112
+ let artifactUrl;
113
+ let uploadProvider = null;
114
+ if (config.publish.zapstoreSource !== "upload") {
115
+ artifactUrl = config.publish.zapstoreSource;
117
116
  }
118
- if (options.dryRun) {
119
- // No network, no APK required. Synthesize a stub descriptor so the events
120
- // can still be built and printed for preview.
121
- apkSha256 = "0".repeat(64);
122
- apkSize = 0;
123
- blossomUrl = `${config.publish.blossom.replace(/\/$/, "")}/${apkSha256}`;
124
- signerCertificateSha256 = "0".repeat(64);
125
- filename = path_1.default.basename(apkPath);
126
- // Stub identity proof so the 30509 event path is exercised end-to-end.
127
- identityProof = {
128
- certHash: signerCertificateSha256,
129
- signature: "dry-run-stub==",
130
- createdAt: Math.floor(Date.now() / 1000),
131
- expiry: Math.floor(Date.now() / 1000) + 365 * 24 * 3600,
132
- pubkeyHex,
133
- };
134
- console.log("📦 (dry-run) Blossom URL:", blossomUrl);
117
+ else if (options.dryRun) {
118
+ uploadProvider = config.publish.upload.provider;
119
+ artifactUrl = config.publish.upload.provider === "blossom"
120
+ ? `${config.publish.upload.server.replace(/\/$/, "")}/${artifact.sha256}`
121
+ : `${config.publish.upload.baseUrl}/dry-run/${encodeURIComponent(artifact.filename)}`;
135
122
  }
136
123
  else {
137
- const blossom = await (0, blossom_1.uploadToBlossom)({
138
- serverUrl: config.publish.blossom,
139
- filePath: apkPath,
140
- secret: publishNsec.bytes,
141
- fetchImpl: options.blossomFetch,
142
- });
143
- blossomUrl = blossom.url;
144
- apkSha256 = blossom.sha256;
145
- apkSize = blossom.size;
146
- console.log("📦 Uploaded to Blossom:", blossom.url);
147
- console.log(" APK SHA-256:", blossom.sha256, "| size:", blossom.size);
148
- }
149
- const releasePublish = await (0, zapStore_1.buildReleaseEvents)({
150
- app,
151
- apk: {
152
- apkSha256,
153
- apkSize,
154
- blossomUrl,
155
- signerCertificateSha256,
156
- filename,
157
- },
158
- publishNsec,
159
- target,
160
- identityProof,
161
- });
162
- await transport.publish(releasePublish.asset, config.publish.relay);
163
- await transport.publish(releasePublish.release, config.publish.relay);
164
- if (releasePublish.identityProof) {
165
- await transport.publish(releasePublish.identityProof, config.publish.relay);
166
- }
167
- const result = {
168
- npub,
169
- pubkeyHex,
170
- appMetadataEventId: appMetadata.id,
171
- releaseEventId: releasePublish.release.id,
172
- assetEventId: releasePublish.asset.id,
173
- identityProofEventId: releasePublish.identityProof?.id,
174
- events: {
175
- appMetadata,
176
- release: releasePublish.release,
177
- asset: releasePublish.asset,
178
- identityProof: releasePublish.identityProof,
179
- },
180
- relayUrl: config.publish.relay,
181
- };
182
- console.log(options.dryRun ? "✅ PUBLISHED (dry-run — no network)" : "✅ PUBLISHED");
183
- console.log("🪪 Publisher npub:", result.npub);
184
- console.log("🧾 Asset event:", result.assetEventId);
185
- console.log("🧾 Release event:", result.releaseEventId);
186
- console.log("🧾 App metadata event:", result.appMetadataEventId);
187
- if (result.identityProofEventId) {
188
- console.log("🧾 Identity proof event:", result.identityProofEventId);
124
+ if (!uploadEnabled)
125
+ throw new Error("Zapstore source is upload, but artifact upload is disabled");
126
+ let uploaded;
127
+ if (config.publish.upload.provider === "blossom") {
128
+ const blossomSecret = publishNsec ?? (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
129
+ const result = await (0, blossom_1.uploadToBlossom)({
130
+ serverUrl: config.publish.upload.server,
131
+ artifact,
132
+ secret: blossomSecret.bytes,
133
+ fetchImpl,
134
+ });
135
+ uploaded = { ...artifact, provider: "blossom", downloadUrl: result.url };
136
+ }
137
+ else {
138
+ const token = process.env[config.publish.upload.tokenEnv];
139
+ if (!token)
140
+ throw new Error(`${config.publish.upload.tokenEnv} is required for Gitea Release upload`);
141
+ uploaded = await new giteaRelease_1.GiteaReleaseUploader({
142
+ ...config.publish.upload,
143
+ token,
144
+ fetchImpl,
145
+ }).upload(artifact, {
146
+ appId: config.app.appId,
147
+ appName: config.app.appName,
148
+ versionName: config.app.versionName,
149
+ versionCode: config.app.versionCode,
150
+ releaseNotes: zapstoreConfig?.releaseNotes,
151
+ });
152
+ }
153
+ uploadProvider = uploaded.provider;
154
+ artifactUrl = uploaded.downloadUrl;
155
+ console.log(`📦 Uploaded via ${uploaded.provider}:`, artifactUrl);
189
156
  }
190
- console.log("🔗 Blossom URL:", blossomUrl);
191
- console.log("📡 Relay:", config.publish.relay);
192
- if (options.verify && !options.dryRun) {
193
- const { eventFound, apkDownloadable } = await (0, zapStore_1.verifyPublish)({
194
- app: {
157
+ // An explicitly enabled uploader still runs when Zapstore uses an external URL or is disabled.
158
+ if (uploadEnabled && config.publish.zapstoreSource !== "upload" && !options.dryRun) {
159
+ if (config.publish.upload.provider === "blossom") {
160
+ const secret = (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
161
+ const uploaded = await (0, blossom_1.uploadToBlossom)({ serverUrl: config.publish.upload.server, artifact, secret: secret.bytes, fetchImpl });
162
+ uploadProvider = "blossom";
163
+ console.log("📦 Uploaded via blossom:", uploaded.url);
164
+ }
165
+ else {
166
+ const token = process.env[config.publish.upload.tokenEnv];
167
+ if (!token)
168
+ throw new Error(`${config.publish.upload.tokenEnv} is required for Gitea Release upload`);
169
+ const uploaded = await new giteaRelease_1.GiteaReleaseUploader({ ...config.publish.upload, token, fetchImpl }).upload(artifact, {
195
170
  appId: config.app.appId,
196
171
  appName: config.app.appName,
197
172
  versionName: config.app.versionName,
198
173
  versionCode: config.app.versionCode,
174
+ releaseNotes: zapstoreConfig?.releaseNotes,
175
+ });
176
+ uploadProvider = uploaded.provider;
177
+ console.log("📦 Uploaded via gitea-release:", uploaded.downloadUrl);
178
+ }
179
+ }
180
+ let zapstoreResult;
181
+ if (zapstoreEnabled) {
182
+ if (!app || !publishNsec || !npub || !pubkeyHex || !appMetadata)
183
+ throw new Error("Zapstore publish context was not initialized");
184
+ if (options.dryRun) {
185
+ identityProof = {
186
+ certHash: signerCertificateSha256,
187
+ signature: "dry-run-stub==",
188
+ createdAt: Math.floor(Date.now() / 1000),
189
+ expiry: Math.floor(Date.now() / 1000) + 365 * 24 * 3600,
190
+ pubkeyHex,
191
+ };
192
+ }
193
+ const built = await (0, zapStore_1.buildReleaseEvents)({
194
+ app,
195
+ apk: {
196
+ apkSha256: artifact.sha256,
197
+ apkSize: artifact.size,
198
+ downloadUrl: artifactUrl,
199
+ signerCertificateSha256,
200
+ filename: artifact.filename,
199
201
  },
200
- assetEventId: result.assetEventId,
201
- relayUrl: config.publish.relay,
202
- blossomUrl,
202
+ publishNsec,
203
+ target: { relayUrl: config.publish.relay },
204
+ identityProof,
203
205
  });
204
- console.log(`🔎 Verify: event on relay=${eventFound}, apk on blossom=${apkDownloadable}`);
205
- if (!eventFound || !apkDownloadable) {
206
- throw new Error("Publish verification failed");
206
+ await transport.publish(built.asset, config.publish.relay);
207
+ await transport.publish(built.release, config.publish.relay);
208
+ if (built.identityProof)
209
+ await transport.publish(built.identityProof, config.publish.relay);
210
+ zapstoreResult = {
211
+ npub,
212
+ pubkeyHex,
213
+ appMetadataEventId: appMetadata.id,
214
+ releaseEventId: built.release.id,
215
+ assetEventId: built.asset.id,
216
+ identityProofEventId: built.identityProof?.id,
217
+ events: { appMetadata, release: built.release, asset: built.asset, identityProof: built.identityProof },
218
+ relayUrl: config.publish.relay,
219
+ };
220
+ }
221
+ if (options.verify && !options.dryRun) {
222
+ if (zapstoreResult) {
223
+ const verified = await (0, zapStore_1.verifyPublish)({
224
+ app: app,
225
+ assetEventId: zapstoreResult.assetEventId,
226
+ relayUrl: config.publish.relay,
227
+ downloadUrl: artifactUrl,
228
+ fetchImpl,
229
+ });
230
+ if (!verified.eventFound || !verified.apkDownloadable)
231
+ throw new Error("Publish verification failed");
232
+ }
233
+ else {
234
+ const response = await (fetchImpl ?? fetch)(artifactUrl, { method: "HEAD" });
235
+ if (!response.ok)
236
+ throw new Error(`Artifact verification failed: HTTP ${response.status}`);
207
237
  }
208
238
  }
239
+ const result = {
240
+ artifactUrl,
241
+ uploadProvider,
242
+ zapstore: zapstoreResult,
243
+ ...(zapstoreResult ?? {}),
244
+ };
245
+ console.log("🔗 Artifact URL:", artifactUrl);
209
246
  if (options.outJson) {
247
+ const blossomServer = config.publish.upload.provider === "blossom" ? config.publish.upload.server : null;
210
248
  const summary = {
211
249
  appId: config.app.appId,
212
250
  appName: config.app.appName,
213
251
  versionName: config.app.versionName,
214
252
  versionCode: config.app.versionCode,
215
- npub: result.npub,
216
- pubkeyHex: result.pubkeyHex,
217
- relayUrl: config.publish.relay,
218
- blossomServer: config.publish.blossom,
219
- blossomUrl,
220
- apkSha256,
221
- apkSize,
253
+ npub,
254
+ pubkeyHex,
255
+ relayUrl: zapstoreEnabled ? config.publish.relay : null,
256
+ artifactUrl,
257
+ source: config.publish.zapstoreSource === "upload" ? "upload" : "url",
258
+ uploadProvider,
259
+ blossomServer,
260
+ blossomUrl: artifactUrl,
261
+ apkSha256: artifact.sha256,
262
+ apkSize: artifact.size,
222
263
  signerCertificateSha256,
223
- assetEventId: result.assetEventId,
224
- releaseEventId: result.releaseEventId,
225
- appMetadataEventId: result.appMetadataEventId,
226
- identityProofEventId: result.identityProofEventId,
264
+ assetEventId: zapstoreResult?.assetEventId,
265
+ releaseEventId: zapstoreResult?.releaseEventId,
266
+ appMetadataEventId: zapstoreResult?.appMetadataEventId,
267
+ identityProofEventId: zapstoreResult?.identityProofEventId,
227
268
  publishedAt: new Date().toISOString(),
228
269
  dryRun: !!options.dryRun,
229
270
  };
@@ -27,8 +27,8 @@ async function runCommand(configPath, options = {}) {
27
27
  const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
28
28
  // Verify PAKSTR_NSEC present (fail fast before any work).
29
29
  (0, nostr_1.requireSigningNsec)(process.env);
30
- // Pre-validate the publish nsec if publishing is enabled (§6 step 1).
31
- if (config.publish.zapstoreEnabled) {
30
+ // Pre-validate the Nostr publish nsec when Zapstore or Blossom needs it.
31
+ if (config.publish.zapstoreEnabled || (config.publish.upload.enabled && config.publish.upload.provider === "blossom")) {
32
32
  if (!(0, nostr_1.isPublishNsecPresent)(config.publish.publishKey, process.env)) {
33
33
  const envVar = config.publish.publishKey === undefined
34
34
  ? "PAKSTR_NSEC"
@@ -44,11 +44,12 @@ async function runCommand(configPath, options = {}) {
44
44
  console.log("Version:", config.app.versionName, `(${config.app.versionCode})`);
45
45
  console.log("Web:", config.build.web);
46
46
  console.log("Out:", path_1.default.resolve(config.build.out));
47
- console.log("Publish:", config.publish.zapstoreEnabled ? "enabled" : "disabled");
47
+ const publishingEnabled = config.publish.zapstoreEnabled || config.publish.upload.enabled;
48
+ console.log("Publish:", publishingEnabled ? "enabled" : "disabled");
48
49
  if (options.dryRun) {
49
50
  // Skip build + sign — no Docker, no APK. Only exercise the publish
50
51
  // event-signing path with stub values and a mock relay.
51
- if (config.publish.zapstoreEnabled) {
52
+ if (publishingEnabled) {
52
53
  console.log("\n— Dry run: skipping build + sign, publish only —");
53
54
  await (0, publish_1.publishCommand)(config.configPath || undefined, {
54
55
  dryRun: true,
@@ -67,8 +68,8 @@ async function runCommand(configPath, options = {}) {
67
68
  // Step 4: sign the APK.
68
69
  console.log("\n— Step 2/3: sign —");
69
70
  await (0, sign_1.signCommand)(config.configPath || undefined);
70
- // Step 5: publish to Zap Store (unless disabled).
71
- if (config.publish.zapstoreEnabled) {
71
+ // Step 5: upload and/or publish to Zapstore (unless both are disabled).
72
+ if (publishingEnabled) {
72
73
  console.log("\n— Step 3/3: publish —");
73
74
  await (0, publish_1.publishCommand)(config.configPath || undefined, {
74
75
  verify: options.verifyPublish,
@@ -76,7 +77,7 @@ async function runCommand(configPath, options = {}) {
76
77
  });
77
78
  }
78
79
  else {
79
- console.log("\nPublish disabled (publish.zapstore.enabled: false). Stopping after sign.");
80
+ console.log("\nArtifact upload and Zapstore publishing are disabled. Stopping after sign.");
80
81
  }
81
82
  // Step 6: cleanup. The signed APK at build.out is the artifact and is kept.
82
83
  // Derived signing material is cleaned up inside the sign runner (tmpfs).
@@ -13,6 +13,7 @@ const branding_1 = require("../android/branding");
13
13
  const icon_1 = require("../android/icon");
14
14
  const permissions_1 = require("../android/permissions");
15
15
  const splash_1 = require("../android/splash");
16
+ const iconResolver_1 = require("./iconResolver");
16
17
  const DEFAULT_ICON_PATH = path_1.default.resolve(__dirname, "../../assets/logo.png");
17
18
  /**
18
19
  * Copy built web assets into the Android template and patch identity/branding.
@@ -31,7 +32,13 @@ async function prepareAndroidProject(opts) {
31
32
  generateRuntimeConfig(assetsTarget);
32
33
  (0, gradle_1.patchGradle)(androidRoot, app);
33
34
  (0, branding_1.patchAppName)(androidRoot, app.appName);
34
- await (0, icon_1.patchIcon)(androidRoot, app.icon ?? DEFAULT_ICON_PATH, app.backgroundColor ?? "#FFFFFF", configDir);
35
+ const icon = await (0, iconResolver_1.resolveAppIcon)({
36
+ configDir,
37
+ configuredIcon: app.icon,
38
+ defaultIcon: DEFAULT_ICON_PATH,
39
+ });
40
+ console.log(`🖼️ Launcher icon source: ${icon.source} (${icon.path})`);
41
+ await (0, icon_1.patchIcon)(androidRoot, icon.path, app.backgroundColor ?? "#FFFFFF", configDir);
35
42
  if (app.splash?.image) {
36
43
  await (0, splash_1.patchSplash)(androidRoot, app.splash.image, app.splash.background ?? "#FFFFFF", configDir);
37
44
  }
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.APK_CONTENT_TYPE = void 0;
7
+ exports.inspectArtifact = inspectArtifact;
8
+ exports.expandArtifactTemplate = expandArtifactTemplate;
9
+ exports.validateDownloadUrl = validateDownloadUrl;
10
+ const crypto_1 = require("crypto");
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ exports.APK_CONTENT_TYPE = "application/vnd.android.package-archive";
14
+ function inspectArtifact(filePath) {
15
+ if (!fs_1.default.existsSync(filePath) || !fs_1.default.lstatSync(filePath).isFile()) {
16
+ throw new Error(`Signed APK not found at ${filePath}. Run \`pakstr build\` and \`pakstr sign\` first.`);
17
+ }
18
+ const hash = (0, crypto_1.createHash)("sha256");
19
+ const fd = fs_1.default.openSync(filePath, "r");
20
+ try {
21
+ const buffer = Buffer.alloc(64 * 1024);
22
+ let bytesRead = 0;
23
+ while ((bytesRead = fs_1.default.readSync(fd, buffer, 0, buffer.length, null)) !== 0) {
24
+ hash.update(buffer.subarray(0, bytesRead));
25
+ }
26
+ }
27
+ finally {
28
+ fs_1.default.closeSync(fd);
29
+ }
30
+ return {
31
+ filePath,
32
+ filename: path_1.default.basename(filePath),
33
+ contentType: exports.APK_CONTENT_TYPE,
34
+ sha256: hash.digest("hex"),
35
+ size: fs_1.default.statSync(filePath).size,
36
+ };
37
+ }
38
+ function expandArtifactTemplate(template, artifact, release) {
39
+ return template
40
+ .replaceAll("{appId}", release.appId)
41
+ .replaceAll("{versionName}", release.versionName)
42
+ .replaceAll("{versionCode}", String(release.versionCode))
43
+ .replaceAll("{filename}", artifact.filename);
44
+ }
45
+ function validateDownloadUrl(value, provider) {
46
+ let url;
47
+ try {
48
+ url = new URL(value);
49
+ }
50
+ catch {
51
+ throw new Error(`${provider} did not return a valid artifact download URL`);
52
+ }
53
+ if ((url.protocol !== "https:" && url.protocol !== "http:") || !url.hostname) {
54
+ throw new Error(`${provider} did not return an HTTP(S) artifact download URL`);
55
+ }
56
+ return url.toString();
57
+ }
@@ -7,8 +7,8 @@ exports.BlossomError = void 0;
7
7
  exports.hashFile = hashFile;
8
8
  exports.uploadToBlossom = uploadToBlossom;
9
9
  exports.npubHexToBech32 = npubHexToBech32;
10
- const crypto_1 = require("crypto");
11
10
  const fs_1 = __importDefault(require("fs"));
11
+ const artifactUpload_1 = require("./artifactUpload");
12
12
  const nostr_1 = require("./nostr");
13
13
  /**
14
14
  * Blossom (BUD-02 / BUD-11) blob upload.
@@ -19,7 +19,7 @@ const nostr_1 = require("./nostr");
19
19
  *
20
20
  * Reference: https://github.com/hzrd149/blossom (BUD-02 upload, BUD-11 auth).
21
21
  */
22
- const DEFAULT_CONTENT_TYPE = "application/vnd.android.package-archive";
22
+ const DEFAULT_CONTENT_TYPE = artifactUpload_1.APK_CONTENT_TYPE;
23
23
  const AUTH_TTL_SECONDS = 5 * 60;
24
24
  class BlossomError extends Error {
25
25
  status;
@@ -32,26 +32,17 @@ class BlossomError extends Error {
32
32
  exports.BlossomError = BlossomError;
33
33
  /** Compute the SHA-256 of a file (hex). */
34
34
  function hashFile(filePath) {
35
- const size = fs_1.default.statSync(filePath).size;
36
- const hash = (0, crypto_1.createHash)("sha256");
37
- const fd = fs_1.default.openSync(filePath, "r");
38
- try {
39
- const buf = Buffer.alloc(64 * 1024);
40
- let bytes = 0;
41
- while ((bytes = fs_1.default.readSync(fd, buf, 0, buf.length, null)) !== 0) {
42
- hash.update(buf.subarray(0, bytes));
43
- }
44
- }
45
- finally {
46
- fs_1.default.closeSync(fd);
47
- }
48
- return { sha256: hash.digest("hex"), size };
35
+ const artifact = (0, artifactUpload_1.inspectArtifact)(filePath);
36
+ return { sha256: artifact.sha256, size: artifact.size };
49
37
  }
50
38
  async function uploadToBlossom(opts) {
51
39
  const fetchFn = opts.fetchImpl ?? fetch;
52
- const contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;
40
+ const artifact = opts.artifact ?? (opts.filePath ? (0, artifactUpload_1.inspectArtifact)(opts.filePath) : undefined);
41
+ if (!artifact)
42
+ throw new BlossomError("Blossom upload requires a local artifact");
43
+ const contentType = opts.contentType ?? artifact.contentType ?? DEFAULT_CONTENT_TYPE;
53
44
  const serverUrl = opts.serverUrl.replace(/\/$/, "");
54
- const { sha256, size } = hashFile(opts.filePath);
45
+ const { sha256, size } = artifact;
55
46
  // 1. HEAD /<sha256> — skip upload if the blob already exists.
56
47
  const head = await fetchFn(`${serverUrl}/${sha256}`, { method: "HEAD" });
57
48
  if (head.status === 200) {
@@ -77,7 +68,7 @@ async function uploadToBlossom(opts) {
77
68
  }, opts.secret);
78
69
  // 3. PUT /upload with the signed auth event in the Authorization header.
79
70
  const authHeader = "Nostr " + Buffer.from(JSON.stringify(authEvent)).toString("base64url");
80
- const body = fs_1.default.readFileSync(opts.filePath);
71
+ const body = fs_1.default.readFileSync(artifact.filePath);
81
72
  const res = await fetchFn(`${serverUrl}/upload`, {
82
73
  method: "PUT",
83
74
  headers: {
@@ -103,12 +94,16 @@ async function uploadToBlossom(opts) {
103
94
  const text = await res.text();
104
95
  try {
105
96
  const descriptor = JSON.parse(text);
97
+ if (descriptor.sha256 && descriptor.sha256 !== sha256) {
98
+ throw new BlossomError("Blossom response SHA-256 does not match the uploaded APK");
99
+ }
100
+ if (descriptor.size !== undefined && descriptor.size !== size) {
101
+ throw new BlossomError("Blossom response size does not match the uploaded APK");
102
+ }
106
103
  if (!descriptor.url)
107
104
  descriptor.url = `${serverUrl}/${sha256}`;
108
- if (!descriptor.sha256)
109
- descriptor.sha256 = sha256;
110
- if (!descriptor.size)
111
- descriptor.size = size;
105
+ descriptor.sha256 = sha256;
106
+ descriptor.size = size;
112
107
  if (!descriptor.type)
113
108
  descriptor.type = contentType;
114
109
  return descriptor;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.GiteaReleaseUploader = exports.GiteaReleaseError = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const artifactUpload_1 = require("./artifactUpload");
9
+ class GiteaReleaseError extends Error {
10
+ status;
11
+ constructor(message, status) {
12
+ super(message);
13
+ this.status = status;
14
+ this.name = "GiteaReleaseError";
15
+ }
16
+ }
17
+ exports.GiteaReleaseError = GiteaReleaseError;
18
+ class GiteaReleaseUploader {
19
+ options;
20
+ provider = "gitea-release";
21
+ constructor(options) {
22
+ this.options = options;
23
+ }
24
+ async upload(artifact, context) {
25
+ const fetchFn = this.options.fetchImpl ?? fetch;
26
+ const repositoryUrl = `${this.options.baseUrl}/api/v1/repos/${encodeURIComponent(this.options.owner)}/${encodeURIComponent(this.options.repo)}`;
27
+ const tag = (0, artifactUpload_1.expandArtifactTemplate)(this.options.tag, artifact, context);
28
+ const assetName = (0, artifactUpload_1.expandArtifactTemplate)(this.options.assetName, artifact, context);
29
+ if (!tag || /[\\\x00-\x1f\x7f]/.test(tag) || tag.includes("..")) {
30
+ throw new GiteaReleaseError("Expanded Gitea release tag is invalid");
31
+ }
32
+ if (!assetName || /[\\/\x00-\x1f\x7f]/.test(assetName) || assetName.includes("..")) {
33
+ throw new GiteaReleaseError("Expanded Gitea asset name must be a single safe filename");
34
+ }
35
+ let release = await this.fetchRelease(fetchFn, repositoryUrl, tag);
36
+ if (!release) {
37
+ const response = await fetchFn(`${repositoryUrl}/releases`, {
38
+ method: "POST",
39
+ headers: this.headers({ "Content-Type": "application/json" }),
40
+ body: JSON.stringify({
41
+ tag_name: tag,
42
+ name: `${context.appName} ${context.versionName}`,
43
+ body: context.releaseNotes ?? "",
44
+ draft: false,
45
+ prerelease: this.options.prerelease,
46
+ }),
47
+ });
48
+ if (response.status === 409 || response.status === 422) {
49
+ release = await this.fetchRelease(fetchFn, repositoryUrl, tag);
50
+ }
51
+ else if (response.ok) {
52
+ release = await this.readRelease(response, "create release");
53
+ }
54
+ else {
55
+ throw await this.httpError(response, "create release");
56
+ }
57
+ if (!release)
58
+ throw new GiteaReleaseError("Gitea release creation raced but the release could not be found");
59
+ }
60
+ const assets = release.assets ?? await this.fetchAssets(fetchFn, repositoryUrl, release.id);
61
+ for (const existing of assets.filter(asset => asset.name === assetName)) {
62
+ const response = await fetchFn(`${repositoryUrl}/releases/assets/${existing.id}`, {
63
+ method: "DELETE",
64
+ headers: this.headers(),
65
+ });
66
+ if (!response.ok && response.status !== 404)
67
+ throw await this.httpError(response, "delete existing asset");
68
+ }
69
+ const form = new FormData();
70
+ form.append("attachment", new Blob([fs_1.default.readFileSync(artifact.filePath)], { type: artifact.contentType }), assetName);
71
+ const response = await fetchFn(`${repositoryUrl}/releases/${release.id}/assets?name=${encodeURIComponent(assetName)}`, {
72
+ method: "POST",
73
+ headers: this.headers(),
74
+ body: form,
75
+ });
76
+ if (!response.ok)
77
+ throw await this.httpError(response, "upload release asset");
78
+ const uploaded = await this.readJson(response, "upload release asset");
79
+ if (!uploaded.id || uploaded.name !== assetName || !uploaded.browser_download_url) {
80
+ throw new GiteaReleaseError("Gitea upload response is missing the expected asset fields");
81
+ }
82
+ return {
83
+ ...artifact,
84
+ provider: this.provider,
85
+ downloadUrl: (0, artifactUpload_1.validateDownloadUrl)(uploaded.browser_download_url, "Gitea"),
86
+ };
87
+ }
88
+ async fetchRelease(fetchFn, repositoryUrl, tag) {
89
+ const response = await fetchFn(`${repositoryUrl}/releases/tags/${encodeURIComponent(tag)}`, { headers: this.headers() });
90
+ if (response.status === 404)
91
+ return undefined;
92
+ if (!response.ok)
93
+ throw await this.httpError(response, "find release");
94
+ return await this.readRelease(response, "find release");
95
+ }
96
+ async fetchAssets(fetchFn, repositoryUrl, releaseId) {
97
+ const response = await fetchFn(`${repositoryUrl}/releases/${releaseId}/assets`, { headers: this.headers() });
98
+ if (!response.ok)
99
+ throw await this.httpError(response, "list release assets");
100
+ const assets = await this.readJson(response, "list release assets");
101
+ if (!Array.isArray(assets))
102
+ throw new GiteaReleaseError("Gitea list-assets response is not an array");
103
+ return assets;
104
+ }
105
+ async readRelease(response, operation) {
106
+ const release = await this.readJson(response, operation);
107
+ if (!Number.isInteger(release.id) || release.id <= 0) {
108
+ throw new GiteaReleaseError(`Gitea ${operation} response is missing a release id`);
109
+ }
110
+ return release;
111
+ }
112
+ async readJson(response, operation) {
113
+ try {
114
+ return await response.json();
115
+ }
116
+ catch {
117
+ throw new GiteaReleaseError(`Gitea ${operation} returned invalid JSON`, response.status);
118
+ }
119
+ }
120
+ headers(extra = {}) {
121
+ return { Authorization: `token ${this.options.token}`, Accept: "application/json", ...extra };
122
+ }
123
+ async httpError(response, operation) {
124
+ let detail = "";
125
+ try {
126
+ detail = (await response.text()).slice(0, 200);
127
+ }
128
+ catch { /* ignore */ }
129
+ if (this.options.token)
130
+ detail = detail.replaceAll(this.options.token, "[REDACTED]");
131
+ return new GiteaReleaseError(`Gitea ${operation} failed for ${this.options.owner}/${this.options.repo} using ${this.options.tokenEnv}: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`, response.status);
132
+ }
133
+ }
134
+ exports.GiteaReleaseUploader = GiteaReleaseUploader;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveAppIcon = resolveAppIcon;
7
+ const child_process_1 = require("child_process");
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const sharp_1 = __importDefault(require("sharp"));
11
+ const assetPath_1 = require("./assetPath");
12
+ async function resolveAppIcon(input) {
13
+ const configDir = fs_1.default.realpathSync(input.configDir);
14
+ const repositoryRoot = findRepositoryRoot(configDir);
15
+ const candidates = [
16
+ { path: path_1.default.join(configDir, "logo.png"), source: "app-logo", root: configDir },
17
+ ];
18
+ if (repositoryRoot && repositoryRoot !== configDir) {
19
+ candidates.push({ path: path_1.default.join(repositoryRoot, "logo.png"), source: "repository-logo", root: repositoryRoot });
20
+ }
21
+ for (const candidate of candidates) {
22
+ if (!fs_1.default.existsSync(candidate.path))
23
+ continue;
24
+ try {
25
+ const resolved = fs_1.default.realpathSync(candidate.path);
26
+ if (!isContained(candidate.root, resolved) || !fs_1.default.statSync(resolved).isFile()) {
27
+ throw new Error("not a regular file inside the application repository");
28
+ }
29
+ const metadata = await (0, sharp_1.default)(resolved).metadata();
30
+ if (metadata.format !== "png" || !metadata.width || !metadata.height) {
31
+ throw new Error("not a non-empty PNG image");
32
+ }
33
+ return { path: resolved, source: candidate.source };
34
+ }
35
+ catch (error) {
36
+ console.warn(`⚠️ Ignoring unsuitable automatic logo ${candidate.path}: ${error instanceof Error ? error.message : String(error)}`);
37
+ }
38
+ }
39
+ if (input.configuredIcon !== undefined) {
40
+ const configured = (0, assetPath_1.resolveAssetPath)(input.configuredIcon, configDir);
41
+ if (!fs_1.default.existsSync(configured) || !fs_1.default.lstatSync(configured).isFile()) {
42
+ throw new Error(`❌ Icon not found or not a regular file: ${configured}`);
43
+ }
44
+ try {
45
+ const metadata = await (0, sharp_1.default)(configured).metadata();
46
+ if (!metadata.width || !metadata.height)
47
+ throw new Error("image has no dimensions");
48
+ }
49
+ catch (error) {
50
+ throw new Error(`❌ Invalid configured icon ${configured}: ${error instanceof Error ? error.message : String(error)}`);
51
+ }
52
+ return { path: configured, source: "configured" };
53
+ }
54
+ return { path: input.defaultIcon, source: "default" };
55
+ }
56
+ function findRepositoryRoot(configDir) {
57
+ try {
58
+ const output = (0, child_process_1.execFileSync)("git", ["-C", configDir, "rev-parse", "--show-toplevel"], {
59
+ encoding: "utf8",
60
+ stdio: ["ignore", "pipe", "ignore"],
61
+ }).trim();
62
+ if (!output)
63
+ return undefined;
64
+ const root = fs_1.default.realpathSync(output);
65
+ return isContained(root, configDir) ? root : undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ function isContained(root, candidate) {
72
+ const relative = path_1.default.relative(root, candidate);
73
+ return relative === "" || (!relative.startsWith(".." + path_1.default.sep) && relative !== "..");
74
+ }
@@ -130,6 +130,9 @@ function resolveAndValidate(config, configPath) {
130
130
  if (app.description !== undefined && typeof app.description !== "string") {
131
131
  fail(`${where("app.description")} must be a string`, configPath);
132
132
  }
133
+ if (app.icon !== undefined && (typeof app.icon !== "string" || app.icon.trim().length === 0)) {
134
+ fail(`${where("app.icon")} must be a non-empty string`, configPath);
135
+ }
133
136
  // Optional color fields
134
137
  if (app.backgroundColor !== undefined) {
135
138
  assertHexColor(app.backgroundColor, "app.backgroundColor", configPath);
@@ -178,20 +181,29 @@ function resolveAndValidate(config, configPath) {
178
181
  // publish section
179
182
  const publish = config.publish;
180
183
  let zapstoreEnabled = true;
184
+ let zapstoreSource = "upload";
181
185
  let publishKey = undefined;
182
186
  if (publish !== undefined) {
183
187
  if (publish.zapstore !== undefined) {
188
+ if (typeof publish.zapstore !== "object" || Array.isArray(publish.zapstore)) {
189
+ fail(`${where("publish.zapstore")} must be a mapping`, configPath);
190
+ }
184
191
  if (publish.zapstore.enabled !== undefined && typeof publish.zapstore.enabled !== "boolean") {
185
192
  fail(`${where("publish.zapstore.enabled")} must be a boolean`, configPath);
186
193
  }
187
194
  zapstoreEnabled = publish.zapstore.enabled ?? true;
195
+ if (publish.zapstore.source !== undefined) {
196
+ requireString(publish.zapstore.source, "publish.zapstore.source", configPath);
197
+ zapstoreSource = publish.zapstore.source === "upload"
198
+ ? "upload"
199
+ : validatePublicUrl(publish.zapstore.source, "publish.zapstore.source", configPath);
200
+ }
188
201
  }
189
202
  if (publish.publishKey !== undefined) {
190
203
  if (publish.publishKey !== null && typeof publish.publishKey !== "string") {
191
204
  fail(`${where("publish.publishKey")} must be a string or empty (bare key)`, configPath);
192
205
  }
193
206
  if (typeof publish.publishKey === "string" && publish.publishKey.trim().length === 0) {
194
- // Treat empty string like a bare key.
195
207
  publishKey = null;
196
208
  }
197
209
  else {
@@ -200,13 +212,72 @@ function resolveAndValidate(config, configPath) {
200
212
  }
201
213
  }
202
214
  const relay = publish?.relay ?? "wss://relay.zapstore.dev";
203
- const blossom = publish?.blossom ?? "https://cdn.zapstore.dev";
215
+ if (publish?.blossom !== undefined && publish.upload !== undefined) {
216
+ fail(`${where("publish.blossom")} cannot be combined with publish.upload`, configPath);
217
+ }
218
+ const defaultBlossom = publish?.blossom ?? "https://cdn.zapstore.dev";
219
+ let upload;
220
+ if (publish?.upload === undefined) {
221
+ upload = {
222
+ enabled: publish?.blossom !== undefined || (zapstoreEnabled && zapstoreSource === "upload"),
223
+ provider: "blossom",
224
+ server: validatePublicUrl(defaultBlossom, "publish.blossom", configPath),
225
+ };
226
+ }
227
+ else {
228
+ const rawUpload = publish.upload;
229
+ if (typeof rawUpload !== "object" || Array.isArray(rawUpload)) {
230
+ fail(`${where("publish.upload")} must be a mapping`, configPath);
231
+ }
232
+ if (rawUpload.enabled !== undefined && typeof rawUpload.enabled !== "boolean") {
233
+ fail(`${where("publish.upload.enabled")} must be a boolean`, configPath);
234
+ }
235
+ if (rawUpload.provider === "blossom") {
236
+ upload = {
237
+ enabled: rawUpload.enabled ?? true,
238
+ provider: "blossom",
239
+ server: validatePublicUrl(rawUpload.server ?? "https://cdn.zapstore.dev", "publish.upload.server", configPath),
240
+ };
241
+ }
242
+ else if (rawUpload.provider === "gitea-release") {
243
+ requireString(rawUpload.baseUrl, "publish.upload.baseUrl", configPath);
244
+ requireSafeSegment(rawUpload.owner, "publish.upload.owner", configPath);
245
+ requireSafeSegment(rawUpload.repo, "publish.upload.repo", configPath);
246
+ const tokenEnv = rawUpload.tokenEnv ?? "PAKSTR_GITEA_TOKEN";
247
+ requireEnvName(tokenEnv, "publish.upload.tokenEnv", configPath);
248
+ const tag = rawUpload.tag ?? "v{versionName}";
249
+ const assetName = rawUpload.assetName ?? "{appId}-{versionName}.apk";
250
+ validateTemplate(tag, "publish.upload.tag", configPath);
251
+ validateTemplate(assetName, "publish.upload.assetName", configPath);
252
+ if (rawUpload.prerelease !== undefined && typeof rawUpload.prerelease !== "boolean") {
253
+ fail(`${where("publish.upload.prerelease")} must be a boolean`, configPath);
254
+ }
255
+ upload = {
256
+ enabled: rawUpload.enabled ?? true,
257
+ provider: "gitea-release",
258
+ baseUrl: validatePublicUrl(rawUpload.baseUrl, "publish.upload.baseUrl", configPath).replace(/\/$/, ""),
259
+ owner: rawUpload.owner,
260
+ repo: rawUpload.repo,
261
+ tokenEnv,
262
+ tag,
263
+ assetName,
264
+ prerelease: rawUpload.prerelease ?? false,
265
+ };
266
+ }
267
+ else {
268
+ fail(`${where("publish.upload.provider")} must be "blossom" or "gitea-release"`, configPath);
269
+ }
270
+ }
271
+ if (zapstoreEnabled && zapstoreSource === "upload" && !upload.enabled) {
272
+ fail(`${where("publish.zapstore.source")} is "upload" but publish.upload is disabled`, configPath);
273
+ }
274
+ const blossom = upload.provider === "blossom" ? upload.server : defaultBlossom;
204
275
  return {
205
276
  configDir,
206
277
  configPath,
207
278
  app: resolvedApp,
208
279
  build: { web: webAbs, out, builder },
209
- publish: { zapstoreEnabled, publishKey, relay, blossom },
280
+ publish: { zapstoreEnabled, zapstoreSource, upload, publishKey, relay, blossom },
210
281
  };
211
282
  }
212
283
  function requireString(value, field, configPath) {
@@ -224,3 +295,41 @@ function assertHexColor(value, field, configPath) {
224
295
  fail(`${field} must be a hex color of the form #RRGGBB`, configPath);
225
296
  }
226
297
  }
298
+ function validatePublicUrl(value, field, configPath) {
299
+ let parsed;
300
+ try {
301
+ parsed = new URL(value);
302
+ }
303
+ catch {
304
+ fail(`${field} must be an absolute HTTP(S) URL`, configPath);
305
+ }
306
+ if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") || !parsed.hostname) {
307
+ fail(`${field} must be an absolute HTTP(S) URL`, configPath);
308
+ }
309
+ if (parsed.username || parsed.password) {
310
+ fail(`${field} must not contain credentials`, configPath);
311
+ }
312
+ return parsed.toString().replace(/\/$/, value.endsWith("/") ? "/" : "");
313
+ }
314
+ function requireSafeSegment(value, field, configPath) {
315
+ requireString(value, field, configPath);
316
+ if (value === "." || value === ".." || /[\\/\x00-\x1f\x7f]/.test(value)) {
317
+ fail(`${field} must be a single non-empty path segment`, configPath);
318
+ }
319
+ }
320
+ function requireEnvName(value, field, configPath) {
321
+ requireString(value, field, configPath);
322
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
323
+ fail(`${field} must be a valid environment variable name`, configPath);
324
+ }
325
+ }
326
+ function validateTemplate(value, field, configPath) {
327
+ requireString(value, field, configPath);
328
+ const stripped = value.replace(/\{(appId|versionName|versionCode|filename)\}/g, "");
329
+ if (/[{}]/.test(stripped)) {
330
+ fail(`${field} contains an unknown or malformed placeholder`, configPath);
331
+ }
332
+ if (/[\\\x00-\x1f\x7f]/.test(value) || value.includes("..")) {
333
+ fail(`${field} must not contain traversal, backslashes, or control characters`, configPath);
334
+ }
335
+ }
@@ -112,7 +112,7 @@ async function buildReleaseEvents(input) {
112
112
  ["i", input.app.appId],
113
113
  ["x", input.apk.apkSha256],
114
114
  ["version", input.app.versionName],
115
- ["url", input.apk.blossomUrl],
115
+ ["url", input.apk.downloadUrl ?? input.apk.blossomUrl ?? ""],
116
116
  ["m", "application/vnd.android.package-archive"],
117
117
  ["size", String(input.apk.apkSize)],
118
118
  ["f", PLATFORM],
@@ -334,13 +334,16 @@ function queryRelayForFilter(WebSocketCtor, relayUrl, filter, timeoutMs = 30000)
334
334
  }
335
335
  /**
336
336
  * Verify a publish by querying the relay for the asset event and confirming
337
- * the APK is retrievable from Blossom. Returns true if both check out.
337
+ * the APK is retrievable from its artifact URL. Returns true if both check out.
338
338
  */
339
339
  async function verifyPublish(input) {
340
340
  const WebSocketCtor = input.WebSocketCtor ?? WebSocket;
341
341
  const fetchFn = input.fetchImpl ?? fetch;
342
342
  const eventFound = await queryRelayForEvent(WebSocketCtor, input.relayUrl, input.assetEventId);
343
- const head = await fetchFn(input.blossomUrl, { method: "HEAD" });
343
+ const artifactUrl = input.downloadUrl ?? input.blossomUrl;
344
+ if (!artifactUrl)
345
+ throw new Error("Publish verification requires an artifact URL");
346
+ const head = await fetchFn(artifactUrl, { method: "HEAD" });
344
347
  return { eventFound, apkDownloadable: head.status === 200 };
345
348
  }
346
349
  /** Open a relay, send a REQ for a single event id, wait for it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",