pakstr 0.25.4 → 0.27.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/README.md CHANGED
@@ -63,8 +63,12 @@ npx pakstr run
63
63
  * `npx pakstr init` — Initialize a Pakstr project.
64
64
  * `npx pakstr sign` — Sign the APK using the key derived from your `nsec`.
65
65
  * `npx pakstr publish` — Publish the signed APK to Zap Store.
66
+ * `npx pakstr delete --app <appId>` — Inventory app-scoped Zapstore deletion; add `--execute` to submit NIP-09 requests, and `--delete-blobs` to include eligible Blossom release blobs.
67
+ * `npx pakstr delete --publisher` — Inventory every event authored by the configured publish nsec; add `--execute` for the publisher-wide NIP-09 deletion request.
66
68
  * `npx pakstr run` — Execute the full pipeline at once (**build** + **sign** + **publish**).
67
69
 
70
+ Delete commands are non-destructive by default. `--app` and `--publisher` are mutually exclusive, and Blossom HTTP deletion requires `--app --execute --delete-blobs`. NIP-09 is best-effort: relay acceptance does not prove physical erasure, and discovery is limited to events retained and returned by the configured relay.
71
+
68
72
  ---
69
73
 
70
74
  ## CI/CD
package/dist/cli.js CHANGED
@@ -10,6 +10,8 @@ const publish_1 = require("./commands/publish");
10
10
  const run_1 = require("./commands/run");
11
11
  const init_1 = require("./commands/init");
12
12
  const nsite_1 = require("./commands/nsite");
13
+ const delete_1 = require("./commands/delete");
14
+ const deletePublisher_1 = require("./commands/deletePublisher");
13
15
  const dotenv_1 = require("./core/dotenv");
14
16
  const package_json_1 = __importDefault(require("../package.json"));
15
17
  async function runCLI(argv) {
@@ -54,6 +56,29 @@ async function runCLI(argv) {
54
56
  outJson: extractFlag(args, "--out-json"),
55
57
  });
56
58
  return;
59
+ case "delete": {
60
+ const appId = extractFlag(args, "--app");
61
+ const publisher = args.includes("--publisher");
62
+ if (appId && publisher)
63
+ throw new Error("pakstr delete requires exactly one of --app or --publisher");
64
+ if (!appId && !publisher)
65
+ throw new Error("pakstr delete requires exactly one of --app or --publisher");
66
+ if (publisher) {
67
+ if (args.includes("--delete-blobs"))
68
+ throw new Error("--delete-blobs is only available with --app");
69
+ await (0, deletePublisher_1.deletePublisherCommand)(configFlag ?? undefined, { execute: args.includes("--execute") });
70
+ }
71
+ else {
72
+ if (appId.startsWith("--"))
73
+ throw new Error("pakstr delete requires --app <appId>");
74
+ await (0, delete_1.deleteCommand)(configFlag ?? undefined, {
75
+ appId: appId,
76
+ execute: args.includes("--execute"),
77
+ deleteBlobs: args.includes("--delete-blobs"),
78
+ });
79
+ }
80
+ return;
81
+ }
57
82
  case "run":
58
83
  await (0, run_1.runCommand)(configFlag ?? undefined, {
59
84
  verifyPublish: args.includes("--verify-publish"),
@@ -84,8 +109,14 @@ Usage:
84
109
  pakstr sign [--config <path>]
85
110
  pakstr nsite [--config <path>] [--dir <path>] [--dry-run]
86
111
  pakstr publish [--config <path>] [--dry-run] [--verify] [--out-json <path>]
112
+ pakstr delete --app <appId> [--config <path>] [--execute] [--delete-blobs]
113
+ pakstr delete --publisher [--config <path>] [--execute]
87
114
  pakstr run [--config <path>] [--dry-run] [--verify-publish] [--publish-out-json <path>]
88
115
 
116
+ delete inventories app-scoped Zapstore events by default; --execute submits NIP-09 requests.
117
+ --delete-blobs inventories release blobs; HTTP DELETE requires --execute --delete-blobs.
118
+ --publisher inventories all configured-publisher events; --execute submits NIP-09 requests only.
119
+
89
120
  pakstr --version
90
121
  pakstr --help
91
122
 
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deleteCommand = deleteCommand;
4
+ const blossom_1 = require("../core/blossom");
5
+ const nostr_1 = require("../core/nostr");
6
+ const pakstrConfig_1 = require("../core/pakstrConfig");
7
+ const zapStore_1 = require("../core/zapStore");
8
+ const zapstoreConfig_1 = require("../core/zapstoreConfig");
9
+ async function deleteCommand(configPath, options) {
10
+ const appId = options.appId;
11
+ if (appId !== appId.trim() || Buffer.byteLength(appId, "utf8") > 255 || !pakstrConfig_1.APP_ID_PATTERN.test(appId)) {
12
+ throw new Error(`Invalid app ID: "${appId}"`);
13
+ }
14
+ const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
15
+ const publishNsec = (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
16
+ const pubkeyHex = await (0, nostr_1.getNpubHex)(publishNsec.bytes);
17
+ const npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
18
+ const zapstoreConfig = (0, zapstoreConfig_1.loadZapstorePublisherConfig)(config.configDir);
19
+ if (zapstoreConfig.pubkeyHex !== pubkeyHex) {
20
+ throw new Error(`Publisher npub mismatch: zapstore.yaml configures ${zapstoreConfig.pubkey}, but the active Pakstr signer is ${npub}`);
21
+ }
22
+ const relayUrl = config.publish.relay;
23
+ const transport = options.transport ?? new zapStore_1.WebsocketRelayTransport();
24
+ const inventory = await (0, zapStore_1.discoverZapStoreAppEvents)(transport, { appId, pubkeyHex, relayUrl });
25
+ const plan = await (0, zapStore_1.buildZapStoreDeletionPlan)(inventory, publishNsec.bytes);
26
+ const blobPlan = options.deleteBlobs
27
+ ? await (0, zapStore_1.buildBlossomDeletionPlan)(inventory, transport, config.publish.blossom)
28
+ : undefined;
29
+ console.log(`\n🗑️ pakstr delete${options.execute ? " --execute" : " (inventory only)"}`);
30
+ console.log("App:", appId);
31
+ console.log("Publisher:", npub);
32
+ console.log("Relay:", relayUrl);
33
+ printTargets(plan);
34
+ if (inventory.rejected.length > 0) {
35
+ console.log(`Rejected candidates (${inventory.rejected.length}):`);
36
+ for (const rejected of inventory.rejected) {
37
+ console.log(` - ${rejected.id ?? "unknown"}: ${rejected.reason}`);
38
+ }
39
+ }
40
+ console.log(`Deletion request batches: ${plan.requests.length}`);
41
+ if (blobPlan)
42
+ printBlobTargets(blobPlan);
43
+ const acceptedRequestIds = [];
44
+ const blobResults = [];
45
+ if (!options.execute) {
46
+ console.log("Inventory only: no deletion requests were published. Re-run with --execute to submit this plan.");
47
+ }
48
+ else if (plan.requests.length === 0) {
49
+ console.log("No validated app events were found; no deletion requests were published.");
50
+ }
51
+ else {
52
+ for (const request of plan.requests) {
53
+ await transport.publish(request, relayUrl);
54
+ acceptedRequestIds.push(request.id);
55
+ console.log(`Relay accepted NIP-09 request: ${request.id}`);
56
+ }
57
+ console.log("Relay acceptance is not proof of physical deletion. NIP-09 is best-effort and copies may remain on relays or clients.");
58
+ }
59
+ if (options.execute && options.deleteBlobs && blobPlan) {
60
+ for (const blob of blobPlan.blobs.filter(item => item.eligible)) {
61
+ const result = await (0, blossom_1.deleteFromBlossom)({
62
+ serverUrl: blob.serverUrl,
63
+ sha256: blob.sha256,
64
+ secret: publishNsec.bytes,
65
+ fetchImpl: options.fetchImpl,
66
+ });
67
+ blobResults.push({ sha256: blob.sha256, result });
68
+ console.log(`Blossom blob ${blob.sha256}: ${result === "deleted" ? "deleted" : "already absent/not found"}`);
69
+ }
70
+ }
71
+ else if (blobPlan && !options.execute) {
72
+ console.log("Blossom inventory only: HTTP DELETE requires both --execute and --delete-blobs.");
73
+ }
74
+ return { appId, npub, pubkeyHex, relayUrl, executed: !!options.execute, inventory, plan, acceptedRequestIds, blobPlan, blobResults };
75
+ }
76
+ function printBlobTargets(plan) {
77
+ console.log("Blossom release blobs:");
78
+ if (plan.blobs.length === 0)
79
+ console.log(" (none)");
80
+ for (const blob of plan.blobs) {
81
+ if (blob.eligible)
82
+ console.log(` DELETE ${blob.serverUrl}/${blob.sha256}`);
83
+ else
84
+ console.log(` SKIP ${blob.url ?? blob.sha256 ?? blob.assetEventIds.join(",")}: ${blob.reason}`);
85
+ }
86
+ }
87
+ function printTargets(plan) {
88
+ console.log("Concrete event IDs (NIP-09 e tags):");
89
+ if (plan.eventIds.length === 0)
90
+ console.log(" (none)");
91
+ for (const target of plan.eventIds)
92
+ console.log(` kind ${target.kind}: ${target.id}`);
93
+ console.log("Addressable coordinates (NIP-09 a tags):");
94
+ if (plan.coordinates.length === 0)
95
+ console.log(" (none)");
96
+ for (const target of plan.coordinates)
97
+ console.log(` kind ${target.kind}: ${target.coordinate}`);
98
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deletePublisherCommand = deletePublisherCommand;
4
+ const nostr_1 = require("../core/nostr");
5
+ const pakstrConfig_1 = require("../core/pakstrConfig");
6
+ const zapStore_1 = require("../core/zapStore");
7
+ async function deletePublisherCommand(configPath, options = {}) {
8
+ const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
9
+ const publishNsec = (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
10
+ const pubkeyHex = await (0, nostr_1.getNpubHex)(publishNsec.bytes);
11
+ const npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
12
+ const relayUrl = config.publish.relay;
13
+ const transport = options.transport ?? new zapStore_1.WebsocketRelayTransport();
14
+ const inventory = await (0, zapStore_1.discoverPublisherEvents)(transport, { pubkeyHex, relayUrl });
15
+ const plan = await (0, zapStore_1.buildPublisherDeletionPlan)(inventory, publishNsec.bytes);
16
+ console.log(`\n🗑️ pakstr delete --publisher${options.execute ? " --execute" : " (inventory only)"}`);
17
+ console.log("Publisher:", npub);
18
+ console.log("Relay:", relayUrl);
19
+ console.log("Concrete event IDs (NIP-09 e tags):");
20
+ if (plan.eventIds.length === 0)
21
+ console.log(" (none)");
22
+ for (const target of plan.eventIds)
23
+ console.log(` kind ${target.kind}: ${target.id}`);
24
+ console.log("Replaceable/addressable coordinates (NIP-09 a tags; covers all versions through this request):");
25
+ if (plan.coordinates.length === 0)
26
+ console.log(" (none)");
27
+ for (const target of plan.coordinates)
28
+ console.log(` kind ${target.kind}: ${target.coordinate}`);
29
+ if (inventory.rejected.length > 0) {
30
+ console.log(`Rejected candidates (${inventory.rejected.length}):`);
31
+ for (const rejected of inventory.rejected)
32
+ console.log(` - ${rejected.id ?? "unknown"}: ${rejected.reason}`);
33
+ }
34
+ console.log(`Deletion request batches: ${plan.requests.length}`);
35
+ const acceptedRequestIds = [];
36
+ if (!options.execute) {
37
+ console.log("Inventory only: no deletion requests were published. Re-run with --execute to submit this plan.");
38
+ }
39
+ else {
40
+ for (const request of plan.requests) {
41
+ try {
42
+ await transport.publish(request, relayUrl);
43
+ }
44
+ catch (error) {
45
+ throw new Error(`Publisher deletion partially completed: ${acceptedRequestIds.length}/${plan.requests.length} NIP-09 request(s) accepted; request ${request.id} failed: ${error instanceof Error ? error.message : String(error)}`);
46
+ }
47
+ acceptedRequestIds.push(request.id);
48
+ console.log(`Relay accepted NIP-09 request: ${request.id}`);
49
+ }
50
+ console.log("Relay acceptance is not proof of physical deletion. NIP-09 is best-effort and copies may remain on relays or clients.");
51
+ }
52
+ return { npub, pubkeyHex, relayUrl, executed: !!options.execute, inventory, plan, acceptedRequestIds };
53
+ }
@@ -7,6 +7,7 @@ exports.initCommand = initCommand;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const crypto_1 = require("crypto");
10
+ const promises_1 = require("readline/promises");
10
11
  const pakstrConfig_1 = require("../core/pakstrConfig");
11
12
  const nostr_1 = require("../core/nostr");
12
13
  /**
@@ -29,8 +30,9 @@ async function initCommand(options = {}) {
29
30
  throw new Error(`${configPath} already exists. Use --force to overwrite, or remove it first.`);
30
31
  }
31
32
  const pkg = readPackageJson();
32
- const { appId, sanitized: appIdSanitized } = inferAppId(pkg);
33
- const appName = inferAppName(pkg) ?? appNameFromAppId(appId);
33
+ const { appId: inferredAppId, sanitized: appIdSanitized } = inferAppId(pkg);
34
+ const { appId, overridden: appIdOverridden } = await confirmAppId(inferredAppId, options);
35
+ const appName = inferAppName(pkg) ?? appNameFromAppId(inferredAppId);
34
36
  const versionName = (pkg && typeof pkg.version === "string" ? pkg.version : "1.0.0");
35
37
  const versionCode = 1;
36
38
  const web = inferWebDir();
@@ -46,7 +48,7 @@ async function initCommand(options = {}) {
46
48
  });
47
49
  fs_1.default.writeFileSync(configPath, yaml, "utf8");
48
50
  console.log(`✅ Wrote ${configPath}`);
49
- if (appIdSanitized) {
51
+ if (appIdSanitized && !appIdOverridden) {
50
52
  console.log(`⚠️ Your package.json name contains characters that aren't valid in an Android package`);
51
53
  console.log(` name (e.g. hyphens). pakstr sanitized it to appId "${appId}". Edit`);
52
54
  console.log(` ${pakstrConfig_1.PAKSTR_CONFIG_FILENAME} if you'd prefer a different value.`);
@@ -235,6 +237,40 @@ function escapeYamlScalar(value) {
235
237
  }
236
238
  return value;
237
239
  }
240
+ async function confirmAppId(inferredAppId, options) {
241
+ const prompt = options.promptAppId ?? (isInteractive() ? promptForAppId : null);
242
+ if (!prompt)
243
+ return { appId: inferredAppId, overridden: false };
244
+ const response = await prompt(inferredAppId);
245
+ if (response === "")
246
+ return { appId: inferredAppId, overridden: false };
247
+ validateAppId(response);
248
+ return { appId: response, overridden: response !== inferredAppId };
249
+ }
250
+ function isInteractive() {
251
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
252
+ }
253
+ async function promptForAppId(defaultAppId) {
254
+ const readline = (0, promises_1.createInterface)({ input: process.stdin, output: process.stdout });
255
+ try {
256
+ return await readline.question(`App ID [${defaultAppId}]: `);
257
+ }
258
+ finally {
259
+ readline.close();
260
+ }
261
+ }
262
+ function validateAppId(appId) {
263
+ if (appId !== appId.trim()) {
264
+ throw new Error("App ID must not have leading or trailing whitespace");
265
+ }
266
+ if (Buffer.byteLength(appId, "utf8") > 255) {
267
+ throw new Error("App ID must be ≤ 255 UTF-8 bytes");
268
+ }
269
+ if (!pakstrConfig_1.APP_ID_PATTERN.test(appId)) {
270
+ throw new Error("App ID must be a valid Android package name: dot-separated segments of" +
271
+ ` lower-case letters, digits, and underscores (no hyphens). Got: "${appId}"`);
272
+ }
273
+ }
238
274
  function readPackageJson() {
239
275
  const p = path_1.default.join(process.cwd(), "package.json");
240
276
  if (!fs_1.default.existsSync(p))
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.BlossomError = void 0;
7
7
  exports.hashFile = hashFile;
8
8
  exports.uploadToBlossom = uploadToBlossom;
9
+ exports.deleteFromBlossom = deleteFromBlossom;
9
10
  exports.npubHexToBech32 = npubHexToBech32;
10
11
  const fs_1 = __importDefault(require("fs"));
11
12
  const artifactUpload_1 = require("./artifactUpload");
@@ -35,13 +36,31 @@ function hashFile(filePath) {
35
36
  const artifact = (0, artifactUpload_1.inspectArtifact)(filePath);
36
37
  return { sha256: artifact.sha256, size: artifact.size };
37
38
  }
39
+ function normalizeServerUrl(serverUrl) {
40
+ return serverUrl.replace(/\/$/, "");
41
+ }
42
+ async function buildAuthorizationHeader(secret, action, sha256, server) {
43
+ const expiration = Math.floor(Date.now() / 1000) + AUTH_TTL_SECONDS;
44
+ const authEvent = await (0, nostr_1.signNostrEvent)({
45
+ kind: 24242,
46
+ created_at: Math.floor(Date.now() / 1000),
47
+ tags: [
48
+ ["t", action],
49
+ ["x", sha256],
50
+ ...(server ? [["server", server]] : []),
51
+ ["expiration", String(expiration)],
52
+ ],
53
+ content: `${action === "upload" ? "Upload" : "Delete"} ${sha256}`,
54
+ }, secret);
55
+ return "Nostr " + Buffer.from(JSON.stringify(authEvent)).toString("base64url");
56
+ }
38
57
  async function uploadToBlossom(opts) {
39
58
  const fetchFn = opts.fetchImpl ?? fetch;
40
59
  const artifact = opts.artifact ?? (opts.filePath ? (0, artifactUpload_1.inspectArtifact)(opts.filePath) : undefined);
41
60
  if (!artifact)
42
61
  throw new BlossomError("Blossom upload requires a local artifact");
43
62
  const contentType = opts.contentType ?? artifact.contentType ?? DEFAULT_CONTENT_TYPE;
44
- const serverUrl = opts.serverUrl.replace(/\/$/, "");
63
+ const serverUrl = normalizeServerUrl(opts.serverUrl);
45
64
  const { sha256, size } = artifact;
46
65
  // 1. HEAD /<sha256> — skip upload if the blob already exists.
47
66
  const head = await fetchFn(`${serverUrl}/${sha256}`, { method: "HEAD" });
@@ -54,20 +73,8 @@ async function uploadToBlossom(opts) {
54
73
  };
55
74
  }
56
75
  // 2. Build + sign the kind-24242 Blossom auth event.
57
- const npubHex = await (0, nostr_1.getNpubHex)(opts.secret);
58
- const expiration = Math.floor(Date.now() / 1000) + AUTH_TTL_SECONDS;
59
- const authEvent = await (0, nostr_1.signNostrEvent)({
60
- kind: 24242,
61
- created_at: Math.floor(Date.now() / 1000),
62
- tags: [
63
- ["t", "upload"],
64
- ["x", sha256],
65
- ["expiration", String(expiration)],
66
- ],
67
- content: `Upload ${sha256}`,
68
- }, opts.secret);
76
+ const authHeader = await buildAuthorizationHeader(opts.secret, "upload", sha256);
69
77
  // 3. PUT /upload with the signed auth event in the Authorization header.
70
- const authHeader = "Nostr " + Buffer.from(JSON.stringify(authEvent)).toString("base64url");
71
78
  const body = fs_1.default.readFileSync(artifact.filePath);
72
79
  const res = await fetchFn(`${serverUrl}/upload`, {
73
80
  method: "PUT",
@@ -112,6 +119,39 @@ async function uploadToBlossom(opts) {
112
119
  return { url: `${serverUrl}/${sha256}`, sha256, size, type: contentType };
113
120
  }
114
121
  }
122
+ /** Delete one content-addressed blob using BUD-11 authorization and BUD-12. */
123
+ async function deleteFromBlossom(opts) {
124
+ if (!/^[0-9a-f]{64}$/.test(opts.sha256)) {
125
+ throw new BlossomError("Blossom delete requires a lowercase hex SHA-256");
126
+ }
127
+ const fetchFn = opts.fetchImpl ?? fetch;
128
+ const serverUrl = normalizeServerUrl(opts.serverUrl);
129
+ let server;
130
+ try {
131
+ server = new URL(serverUrl);
132
+ }
133
+ catch {
134
+ throw new BlossomError(`Invalid Blossom server URL: ${opts.serverUrl}`);
135
+ }
136
+ if (!/^https?:$/.test(server.protocol) || !server.hostname || server.username || server.password || server.search || server.hash) {
137
+ throw new BlossomError(`Invalid Blossom server URL: ${opts.serverUrl}`);
138
+ }
139
+ const authHeader = await buildAuthorizationHeader(opts.secret, "delete", opts.sha256, server.hostname.toLowerCase());
140
+ const res = await fetchFn(`${serverUrl}/${opts.sha256}`, {
141
+ method: "DELETE",
142
+ headers: { Authorization: authHeader },
143
+ });
144
+ if (res.status === 200 || res.status === 204)
145
+ return "deleted";
146
+ if (res.status === 404)
147
+ return "absent";
148
+ let detail = "";
149
+ try {
150
+ detail = await res.text();
151
+ }
152
+ catch { /* ignore */ }
153
+ throw new BlossomError(`Blossom delete failed: HTTP ${res.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`, res.status);
154
+ }
115
155
  /** Convenience: hex npub → bech32 npub string for display. */
116
156
  function npubHexToBech32(npubHex) {
117
157
  return (0, nostr_1.pubkeyHexToNpub)(npubHex);
@@ -4,15 +4,22 @@ exports.MockRelayTransport = exports.WebsocketRelayTransport = exports.IDENTITY_
4
4
  exports.formatIdentityProofMessage = formatIdentityProofMessage;
5
5
  exports.findAppMetadataEvent = findAppMetadataEvent;
6
6
  exports.isCurrentAppMetadataEvent = isCurrentAppMetadataEvent;
7
+ exports.discoverPublisherEvents = discoverPublisherEvents;
8
+ exports.buildPublisherDeletionPlan = buildPublisherDeletionPlan;
9
+ exports.discoverZapStoreAppEvents = discoverZapStoreAppEvents;
10
+ exports.buildZapStoreDeletionPlan = buildZapStoreDeletionPlan;
11
+ exports.buildBlossomDeletionPlan = buildBlossomDeletionPlan;
7
12
  exports.buildAppMetadataEvent = buildAppMetadataEvent;
8
13
  exports.buildReleaseEvents = buildReleaseEvents;
9
14
  exports.buildSignedPublishEvents = buildSignedPublishEvents;
10
15
  exports.publishToZapStore = publishToZapStore;
11
16
  exports.resolvePublishSecret = resolvePublishSecret;
12
17
  exports.publishEventToRelay = publishEventToRelay;
18
+ exports.queryRelayForEvents = queryRelayForEvents;
13
19
  exports.queryRelayForFilter = queryRelayForFilter;
14
20
  exports.verifyPublish = verifyPublish;
15
21
  exports.queryRelayForEvent = queryRelayForEvent;
22
+ const helpers_1 = require("applesauce-core/helpers");
16
23
  const nostr_1 = require("./nostr");
17
24
  /** Default Zap Store catalog community (h tag). */
18
25
  exports.DEFAULT_COMMUNITY = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84";
@@ -41,6 +48,8 @@ function formatIdentityProofMessage(createdAt, expiry, pubkeyHex) {
41
48
  const PLATFORM = "android-arm64-v8a";
42
49
  const MIN_SDK = 28;
43
50
  const TARGET_SDK = 36;
51
+ const DELETE_BATCH_SIZE = 100;
52
+ const HEX_EVENT_ID = /^[0-9a-f]{64}$/;
44
53
  /** Query for this publisher's replaceable application event. */
45
54
  async function findAppMetadataEvent(transport, input) {
46
55
  if (!transport.query)
@@ -71,6 +80,302 @@ function isCurrentAppMetadataEvent(existing, expected) {
71
80
  return expected.tags.every(expectedTag => existing.tags.some(existingTag => existingTag.length === expectedTag.length
72
81
  && existingTag.every((value, index) => value === expectedTag[index])));
73
82
  }
83
+ function exactTagValue(event, name) {
84
+ const tags = event.tags.filter(tag => tag[0] === name);
85
+ return tags.length === 1 && tags[0].length >= 2 ? tags[0][1] : undefined;
86
+ }
87
+ function validateCandidate(event, expected) {
88
+ if (!HEX_EVENT_ID.test(event.id))
89
+ return "invalid event id";
90
+ if (event.kind !== expected.kind)
91
+ return `unexpected kind ${event.kind}`;
92
+ if (event.pubkey !== expected.pubkeyHex)
93
+ return "unexpected publisher";
94
+ try {
95
+ if (!(0, helpers_1.verifyEvent)(event))
96
+ return "invalid event signature or id";
97
+ }
98
+ catch {
99
+ return "invalid event signature or id";
100
+ }
101
+ return undefined;
102
+ }
103
+ function validatePublisherCandidate(event, pubkeyHex) {
104
+ if (!event || typeof event !== "object")
105
+ return "invalid event structure";
106
+ if (!HEX_EVENT_ID.test(event.id) || !HEX_EVENT_ID.test(event.pubkey))
107
+ return "invalid event id or pubkey";
108
+ if (!Number.isSafeInteger(event.kind) || event.kind < 0)
109
+ return "invalid event kind";
110
+ if (!Number.isSafeInteger(event.created_at) || event.created_at < 0)
111
+ return "invalid event timestamp";
112
+ if (typeof event.content !== "string" || !Array.isArray(event.tags)
113
+ || event.tags.some(tag => !Array.isArray(tag) || tag.some(value => typeof value !== "string"))) {
114
+ return "invalid event structure";
115
+ }
116
+ if (event.pubkey !== pubkeyHex)
117
+ return "unexpected publisher";
118
+ try {
119
+ if (!(0, helpers_1.verifyEvent)(event))
120
+ return "invalid event signature or id";
121
+ }
122
+ catch {
123
+ return "invalid event signature or id";
124
+ }
125
+ return undefined;
126
+ }
127
+ /** Discover and validate every event authored by a publisher through EOSE. */
128
+ async function discoverPublisherEvents(transport, input) {
129
+ if (!transport.queryAll)
130
+ throw new Error("Relay transport does not support exhaustive queries");
131
+ const candidates = await transport.queryAll({ authors: [input.pubkeyHex] }, input.relayUrl);
132
+ const events = [];
133
+ const rejected = [];
134
+ const validIds = new Set();
135
+ for (const event of candidates) {
136
+ const invalid = validatePublisherCandidate(event, input.pubkeyHex);
137
+ if (invalid) {
138
+ rejected.push({ ...(typeof event?.id === "string" ? { id: event.id } : {}), reason: invalid });
139
+ }
140
+ else if (!validIds.has(event.id)) {
141
+ validIds.add(event.id);
142
+ events.push(event);
143
+ }
144
+ }
145
+ return { ...input, events, rejected };
146
+ }
147
+ /** Build bounded NIP-09 requests for every validated publisher event. */
148
+ async function buildPublisherDeletionPlan(inventory, secret, options = {}) {
149
+ const eventIds = inventory.events.map(event => ({ kind: event.kind, id: event.id }));
150
+ const coordinateById = new Map();
151
+ for (const event of inventory.events) {
152
+ if (!(0, helpers_1.isReplaceableKind)(event.kind) && !(0, helpers_1.isAddressableKind)(event.kind))
153
+ continue;
154
+ if ((0, helpers_1.isAddressableKind)(event.kind) && exactTagValue(event, "d") === undefined)
155
+ continue;
156
+ const coordinate = (0, helpers_1.getReplaceableAddressForEvent)(event);
157
+ if (coordinate)
158
+ coordinateById.set(event.id, coordinate);
159
+ }
160
+ const coordinates = [...new Set(coordinateById.values())].map(coordinate => ({
161
+ kind: Number(coordinate.slice(0, coordinate.indexOf(":"))),
162
+ coordinate,
163
+ }));
164
+ const batchSize = options.batchSize ?? DELETE_BATCH_SIZE;
165
+ if (!Number.isInteger(batchSize) || batchSize <= 0)
166
+ throw new Error("Deletion batch size must be a positive integer");
167
+ const requests = [];
168
+ for (let offset = 0; offset < inventory.events.length; offset += batchSize) {
169
+ const batch = inventory.events.slice(offset, offset + batchSize);
170
+ const batchCoordinates = [...new Set(batch.map(event => coordinateById.get(event.id)).filter((value) => !!value))];
171
+ const kinds = [...new Set(batch.map(event => event.kind))];
172
+ requests.push(await (0, nostr_1.signNostrEvent)({
173
+ kind: 5,
174
+ created_at: Math.floor((options.now?.() ?? Date.now()) / 1000),
175
+ tags: [
176
+ ...batch.map(event => ["e", event.id]),
177
+ ...batchCoordinates.map(coordinate => ["a", coordinate]),
178
+ ...kinds.map(kind => ["k", String(kind)]),
179
+ ],
180
+ content: "Deleted by pakstr for publisher",
181
+ }, secret));
182
+ }
183
+ return { eventIds, coordinates, requests };
184
+ }
185
+ /** Discover and validate every app-scoped Zapstore event returned through EOSE. */
186
+ async function discoverZapStoreAppEvents(transport, input) {
187
+ if (!transport.queryAll)
188
+ throw new Error("Relay transport does not support exhaustive queries");
189
+ const [appCandidates, releaseCandidates, assetCandidates] = await Promise.all([
190
+ transport.queryAll({ kinds: [exports.KIND_APP_METADATA], authors: [input.pubkeyHex], "#d": [input.appId] }, input.relayUrl),
191
+ transport.queryAll({ kinds: [exports.KIND_RELEASE], authors: [input.pubkeyHex], "#i": [input.appId] }, input.relayUrl),
192
+ transport.queryAll({ kinds: [exports.KIND_ASSET], authors: [input.pubkeyHex], "#i": [input.appId] }, input.relayUrl),
193
+ ]);
194
+ const rejected = [];
195
+ const reject = (event, reason) => {
196
+ rejected.push({ ...(typeof event.id === "string" ? { id: event.id } : {}), reason });
197
+ };
198
+ const unique = (events) => [...new Map(events.map(event => [event.id, event])).values()];
199
+ const appMetadata = unique(appCandidates).filter(event => {
200
+ const invalid = validateCandidate(event, { kind: exports.KIND_APP_METADATA, pubkeyHex: input.pubkeyHex });
201
+ if (invalid) {
202
+ reject(event, invalid);
203
+ return false;
204
+ }
205
+ if (exactTagValue(event, "d") !== input.appId) {
206
+ reject(event, "invalid app metadata d tag");
207
+ return false;
208
+ }
209
+ return true;
210
+ });
211
+ const assets = unique(assetCandidates).filter(event => {
212
+ const invalid = validateCandidate(event, { kind: exports.KIND_ASSET, pubkeyHex: input.pubkeyHex });
213
+ if (invalid) {
214
+ reject(event, invalid);
215
+ return false;
216
+ }
217
+ if (exactTagValue(event, "i") !== input.appId) {
218
+ reject(event, "invalid asset i tag");
219
+ return false;
220
+ }
221
+ return true;
222
+ });
223
+ const assetIds = new Set(assets.map(event => event.id));
224
+ const releases = unique(releaseCandidates).filter(event => {
225
+ const invalid = validateCandidate(event, { kind: exports.KIND_RELEASE, pubkeyHex: input.pubkeyHex });
226
+ if (invalid) {
227
+ reject(event, invalid);
228
+ return false;
229
+ }
230
+ if (exactTagValue(event, "i") !== input.appId) {
231
+ reject(event, "invalid release i tag");
232
+ return false;
233
+ }
234
+ const d = exactTagValue(event, "d");
235
+ if (!d || !d.startsWith(`${input.appId}@`) || d.length === input.appId.length + 1) {
236
+ reject(event, "invalid release d tag");
237
+ return false;
238
+ }
239
+ const linkedAssets = event.tags
240
+ .filter(tag => tag[0] === "e" && typeof tag[1] === "string" && HEX_EVENT_ID.test(tag[1]))
241
+ .map(tag => tag[1]);
242
+ if (!linkedAssets.some(id => assetIds.has(id))) {
243
+ reject(event, "release does not reference a validated app asset");
244
+ return false;
245
+ }
246
+ return true;
247
+ });
248
+ return { ...input, appMetadata, releases, assets, rejected };
249
+ }
250
+ /** Build signed, bounded NIP-09 deletion requests for a validated inventory. */
251
+ async function buildZapStoreDeletionPlan(inventory, secret, options = {}) {
252
+ const targets = [...inventory.appMetadata, ...inventory.releases, ...inventory.assets];
253
+ const eventIds = targets.map(event => ({ kind: event.kind, id: event.id }));
254
+ const coordinates = [];
255
+ const seenCoordinates = new Set();
256
+ for (const event of [...inventory.appMetadata, ...inventory.releases]) {
257
+ const d = exactTagValue(event, "d");
258
+ if (!d)
259
+ continue;
260
+ const coordinate = `${event.kind}:${inventory.pubkeyHex}:${d}`;
261
+ if (!seenCoordinates.has(coordinate)) {
262
+ seenCoordinates.add(coordinate);
263
+ coordinates.push({ kind: event.kind, coordinate });
264
+ }
265
+ }
266
+ const batchSize = options.batchSize ?? DELETE_BATCH_SIZE;
267
+ if (!Number.isInteger(batchSize) || batchSize <= 0)
268
+ throw new Error("Deletion batch size must be a positive integer");
269
+ const requests = [];
270
+ for (let offset = 0; offset < targets.length; offset += batchSize) {
271
+ const batch = targets.slice(offset, offset + batchSize);
272
+ const kinds = [...new Set(batch.map(event => event.kind))];
273
+ const batchCoordinates = [...new Set(batch
274
+ .filter(event => event.kind === exports.KIND_APP_METADATA || event.kind === exports.KIND_RELEASE)
275
+ .map(event => `${event.kind}:${inventory.pubkeyHex}:${exactTagValue(event, "d")}`))];
276
+ const tags = [
277
+ ...batch.map(event => ["e", event.id]),
278
+ ...batchCoordinates.map(coordinate => ["a", coordinate]),
279
+ ...kinds.map(kind => ["k", String(kind)]),
280
+ ];
281
+ requests.push(await (0, nostr_1.signNostrEvent)({
282
+ kind: 5,
283
+ created_at: Math.floor((options.now?.() ?? Date.now()) / 1000),
284
+ tags,
285
+ content: `Deleted by pakstr for app ${inventory.appId}`,
286
+ }, secret));
287
+ }
288
+ return { eventIds, coordinates, requests };
289
+ }
290
+ function validateBlobUrl(value, sha256) {
291
+ let url;
292
+ try {
293
+ url = new URL(value);
294
+ }
295
+ catch {
296
+ return { reason: "asset url is not an absolute URL" };
297
+ }
298
+ if (!/^https?:$/.test(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash) {
299
+ return { reason: "asset url is not a safe HTTP(S) Blossom URL" };
300
+ }
301
+ const suffix = `/${sha256}`;
302
+ if (!url.pathname.endsWith(suffix) || url.pathname.slice(0, -suffix.length).endsWith("/")) {
303
+ return { reason: "asset url does not end with its x hash" };
304
+ }
305
+ url.pathname = url.pathname.slice(0, -suffix.length) || "/";
306
+ url.hostname = url.hostname.toLowerCase();
307
+ return { serverUrl: url.toString().replace(/\/$/, "") };
308
+ }
309
+ /** Build a conservative Blossom plan, proving each hash is not referenced outside the selected app. */
310
+ async function buildBlossomDeletionPlan(inventory, transport, configuredServerUrl) {
311
+ if (!transport.queryAll)
312
+ throw new Error("Relay transport does not support exhaustive queries");
313
+ let configuredServer;
314
+ try {
315
+ const parsedConfiguredServer = new URL(configuredServerUrl);
316
+ parsedConfiguredServer.hostname = parsedConfiguredServer.hostname.toLowerCase();
317
+ configuredServer = parsedConfiguredServer.toString().replace(/\/$/, "");
318
+ }
319
+ catch {
320
+ throw new Error(`Invalid configured Blossom server URL: ${configuredServerUrl}`);
321
+ }
322
+ const blobs = [];
323
+ const candidates = new Map();
324
+ for (const asset of inventory.assets) {
325
+ const sha256 = exactTagValue(asset, "x");
326
+ const url = exactTagValue(asset, "url");
327
+ if (!sha256 || !HEX_EVENT_ID.test(sha256)) {
328
+ blobs.push({ sha256, url, assetEventIds: [asset.id], eligible: false, reason: "asset has no unique lowercase hex x hash" });
329
+ continue;
330
+ }
331
+ if (!url) {
332
+ blobs.push({ sha256, assetEventIds: [asset.id], eligible: false, reason: "asset has no unique url tag" });
333
+ continue;
334
+ }
335
+ const parsed = validateBlobUrl(url, sha256);
336
+ if ("reason" in parsed) {
337
+ blobs.push({ sha256, url, assetEventIds: [asset.id], eligible: false, reason: parsed.reason });
338
+ continue;
339
+ }
340
+ if (parsed.serverUrl !== configuredServer) {
341
+ blobs.push({ sha256, url, serverUrl: parsed.serverUrl, assetEventIds: [asset.id], eligible: false, reason: "asset url does not use the configured Blossom server" });
342
+ continue;
343
+ }
344
+ const key = `${parsed.serverUrl}/${sha256}`;
345
+ const existing = candidates.get(key);
346
+ if (existing)
347
+ existing.assetEventIds.push(asset.id);
348
+ else
349
+ candidates.set(key, { sha256, url, serverUrl: parsed.serverUrl, assetEventIds: [asset.id], eligible: false });
350
+ }
351
+ const checked = await Promise.all([...candidates.values()].map(async (blob) => {
352
+ const references = await transport.queryAll({ kinds: [exports.KIND_ASSET], "#x": [blob.sha256] }, inventory.relayUrl);
353
+ const validReferences = [];
354
+ let invalidCount = 0;
355
+ for (const reference of references) {
356
+ if (validateCandidate(reference, { kind: exports.KIND_ASSET, pubkeyHex: reference.pubkey }) || exactTagValue(reference, "x") !== blob.sha256) {
357
+ invalidCount++;
358
+ continue;
359
+ }
360
+ validReferences.push(reference);
361
+ }
362
+ const returnedIds = new Set(validReferences.map(reference => reference.id));
363
+ if (!blob.assetEventIds.every(id => returnedIds.has(id))) {
364
+ return { ...blob, reason: "shared-reference query did not return every source asset" };
365
+ }
366
+ const unsafe = validReferences.find(reference => reference.pubkey !== inventory.pubkeyHex || exactTagValue(reference, "i") !== inventory.appId);
367
+ if (unsafe) {
368
+ const appId = exactTagValue(unsafe, "i");
369
+ return { ...blob, reason: appId ? `hash is also referenced by app ${appId}` : "hash reference has ambiguous app ownership" };
370
+ }
371
+ if (invalidCount > 0) {
372
+ return { ...blob, reason: "relay returned invalid or ambiguous hash references" };
373
+ }
374
+ return { ...blob, eligible: true };
375
+ }));
376
+ blobs.push(...checked);
377
+ return { blobs };
378
+ }
74
379
  /** Build the kind-32267 application event independently of the APK upload. */
75
380
  async function buildAppMetadataEvent(input) {
76
381
  const pubkeyHex = await (0, nostr_1.getNpubHex)(input.publishNsec.bytes);
@@ -217,6 +522,9 @@ class WebsocketRelayTransport {
217
522
  async query(filter, relayUrl) {
218
523
  return await queryRelayForFilter(this.WebSocketCtor, relayUrl, filter);
219
524
  }
525
+ async queryAll(filter, relayUrl) {
526
+ return await queryRelayForEvents(this.WebSocketCtor, relayUrl, filter);
527
+ }
220
528
  async publish(event, relayUrl) {
221
529
  await publishEventToRelay(this.WebSocketCtor, event, relayUrl);
222
530
  }
@@ -228,6 +536,9 @@ class MockRelayTransport {
228
536
  async query(_filter, _relayUrl) {
229
537
  return undefined;
230
538
  }
539
+ async queryAll(_filter, _relayUrl) {
540
+ return [];
541
+ }
231
542
  async publish(event, _relayUrl) {
232
543
  this.published.push(event);
233
544
  }
@@ -286,6 +597,56 @@ function publishEventToRelay(WebSocketCtor, event, relayUrl, timeoutMs = 30000)
286
597
  };
287
598
  });
288
599
  }
600
+ /** Query a relay for every event through EOSE. Partial results are never returned. */
601
+ function queryRelayForEvents(WebSocketCtor, relayUrl, filter, timeoutMs = 30000) {
602
+ return new Promise((resolve, reject) => {
603
+ let settled = false;
604
+ const events = [];
605
+ const subscriptionId = `pakstr-all-${Date.now().toString(36)}`;
606
+ const ws = new WebSocketCtor(relayUrl);
607
+ const finish = (error) => {
608
+ if (settled)
609
+ return;
610
+ settled = true;
611
+ clearTimeout(timer);
612
+ try {
613
+ ws.send(JSON.stringify(["CLOSE", subscriptionId]));
614
+ }
615
+ catch { /* ignore */ }
616
+ try {
617
+ ws.close();
618
+ }
619
+ catch { /* ignore */ }
620
+ if (error)
621
+ reject(error);
622
+ else
623
+ resolve(events);
624
+ };
625
+ const timer = setTimeout(() => finish(new Error(`Relay query timed out: ${relayUrl}`)), timeoutMs);
626
+ ws.onopen = () => ws.send(JSON.stringify(["REQ", subscriptionId, filter]));
627
+ ws.onmessage = (msg) => {
628
+ let data;
629
+ try {
630
+ data = JSON.parse(typeof msg.data === "string" ? msg.data : msg.data.toString());
631
+ }
632
+ catch {
633
+ return;
634
+ }
635
+ if (!Array.isArray(data) || data[1] !== subscriptionId)
636
+ return;
637
+ if (data[0] === "EVENT") {
638
+ events.push(data[2]);
639
+ }
640
+ else if (data[0] === "EOSE") {
641
+ finish();
642
+ }
643
+ else if (data[0] === "CLOSED") {
644
+ finish(new Error(`Relay closed query: ${relayUrl} ${data[2] ?? "unknown"}`));
645
+ }
646
+ };
647
+ ws.onerror = (error) => finish(new Error(`Relay connection error: ${relayUrl} ${error?.message ?? ""}`));
648
+ });
649
+ }
289
650
  /** Query a relay for the first event matching a Nostr filter. */
290
651
  function queryRelayForFilter(WebSocketCtor, relayUrl, filter, timeoutMs = 30000) {
291
652
  return new Promise((resolve, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.25.4",
3
+ "version": "0.27.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",