pakstr 0.8.7 → 0.9.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.
@@ -0,0 +1,137 @@
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.publishCommand = publishCommand;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const pakstrConfig_1 = require("../core/pakstrConfig");
10
+ const zapStore_1 = require("../core/zapStore");
11
+ const blossom_1 = require("../core/blossom");
12
+ /**
13
+ * `pakstr publish` — upload the signed APK to Blossom and publish NIP-82
14
+ * events (32267/30063/3063) to the configured relay. Spec §5.4 / §8.
15
+ *
16
+ * Requires that `pakstr build` + `pakstr sign` have already run (the signed
17
+ * APK at build.out, plus its `.signer-sha256` sidecar).
18
+ *
19
+ * `--dry-run` skips network + disk: it builds the events with a stub blossom
20
+ * descriptor and a mock relay transport, so you can preview exactly what would
21
+ * be published without an APK or network.
22
+ */
23
+ async function publishCommand(configPath, options = {}) {
24
+ const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
25
+ const apkPath = path_1.default.resolve(config.build.out);
26
+ const publishNsec = (0, zapStore_1.resolvePublishSecret)(config.publish.publishKey, process.env);
27
+ console.log("\n📤 pakstr publish" + (options.dryRun ? " (dry-run)" : ""));
28
+ console.log("App:", config.app.appName, `(${config.app.appId})`);
29
+ console.log("Publishing as:", publishNsec.envVar);
30
+ console.log("Relay:", config.publish.relay);
31
+ console.log("Blossom:", config.publish.blossom);
32
+ let blossomUrl;
33
+ let apkSha256;
34
+ let apkSize;
35
+ let signerCertificateSha256;
36
+ let filename;
37
+ if (options.dryRun) {
38
+ // No network, no APK required. Synthesize a stub descriptor so the events
39
+ // can still be built and printed for preview.
40
+ apkSha256 = "0".repeat(64);
41
+ apkSize = 0;
42
+ blossomUrl = `${config.publish.blossom.replace(/\/$/, "")}/${apkSha256}`;
43
+ signerCertificateSha256 = "0".repeat(64);
44
+ filename = path_1.default.basename(apkPath);
45
+ console.log("📦 (dry-run) Blossom URL:", blossomUrl);
46
+ }
47
+ else {
48
+ if (!fs_1.default.existsSync(apkPath) || !fs_1.default.lstatSync(apkPath).isFile()) {
49
+ throw new Error(`Signed APK not found at ${apkPath}. Run \`pakstr build\` and \`pakstr sign\` first.`);
50
+ }
51
+ const certShaPath = `${apkPath}.signer-sha256`;
52
+ if (!fs_1.default.existsSync(certShaPath)) {
53
+ throw new Error(`Signer certificate sidecar not found: ${certShaPath}. Run \`pakstr sign\` first.`);
54
+ }
55
+ signerCertificateSha256 = fs_1.default.readFileSync(certShaPath, "utf8").trim();
56
+ const blossom = await (0, blossom_1.uploadToBlossom)({
57
+ serverUrl: config.publish.blossom,
58
+ filePath: apkPath,
59
+ secret: publishNsec.bytes,
60
+ fetchImpl: options.blossomFetch,
61
+ });
62
+ blossomUrl = blossom.url;
63
+ apkSha256 = blossom.sha256;
64
+ apkSize = blossom.size;
65
+ filename = path_1.default.basename(apkPath);
66
+ console.log("📦 Uploaded to Blossom:", blossom.url);
67
+ console.log(" APK SHA-256:", blossom.sha256, "| size:", blossom.size);
68
+ }
69
+ // Build + publish the NIP-82 events.
70
+ const transport = options.transport ?? (options.dryRun ? new zapStore_1.MockRelayTransport() : new zapStore_1.WebsocketRelayTransport());
71
+ const result = await (0, zapStore_1.publishToZapStore)({
72
+ app: {
73
+ appId: config.app.appId,
74
+ appName: config.app.appName,
75
+ versionName: config.app.versionName,
76
+ versionCode: config.app.versionCode,
77
+ description: config.app.description,
78
+ },
79
+ apk: {
80
+ apkSha256,
81
+ apkSize,
82
+ blossomUrl,
83
+ signerCertificateSha256,
84
+ filename,
85
+ },
86
+ publishNsec,
87
+ target: { relayUrl: config.publish.relay },
88
+ }, transport);
89
+ console.log(options.dryRun ? "✅ PUBLISHED (dry-run — no network)" : "✅ PUBLISHED");
90
+ console.log("🪪 Publisher npub:", result.npub);
91
+ console.log("🧾 Asset event:", result.assetEventId);
92
+ console.log("🧾 Release event:", result.releaseEventId);
93
+ console.log("🧾 App metadata event:", result.appMetadataEventId);
94
+ console.log("🔗 Blossom URL:", blossomUrl);
95
+ console.log("📡 Relay:", config.publish.relay);
96
+ if (options.verify && !options.dryRun) {
97
+ const { eventFound, apkDownloadable } = await (0, zapStore_1.verifyPublish)({
98
+ app: {
99
+ appId: config.app.appId,
100
+ appName: config.app.appName,
101
+ versionName: config.app.versionName,
102
+ versionCode: config.app.versionCode,
103
+ },
104
+ assetEventId: result.assetEventId,
105
+ relayUrl: config.publish.relay,
106
+ blossomUrl,
107
+ });
108
+ console.log(`🔎 Verify: event on relay=${eventFound}, apk on blossom=${apkDownloadable}`);
109
+ if (!eventFound || !apkDownloadable) {
110
+ throw new Error("Publish verification failed");
111
+ }
112
+ }
113
+ if (options.outJson) {
114
+ const summary = {
115
+ appId: config.app.appId,
116
+ appName: config.app.appName,
117
+ versionName: config.app.versionName,
118
+ versionCode: config.app.versionCode,
119
+ npub: result.npub,
120
+ pubkeyHex: result.pubkeyHex,
121
+ relayUrl: config.publish.relay,
122
+ blossomServer: config.publish.blossom,
123
+ blossomUrl,
124
+ apkSha256,
125
+ apkSize,
126
+ signerCertificateSha256,
127
+ assetEventId: result.assetEventId,
128
+ releaseEventId: result.releaseEventId,
129
+ appMetadataEventId: result.appMetadataEventId,
130
+ publishedAt: new Date().toISOString(),
131
+ dryRun: !!options.dryRun,
132
+ };
133
+ fs_1.default.writeFileSync(options.outJson, JSON.stringify(summary, null, 2) + "\n", "utf8");
134
+ console.log("📝 Publish summary written to:", options.outJson);
135
+ }
136
+ return result;
137
+ }
@@ -0,0 +1,95 @@
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.runCommand = runCommand;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const pakstrConfig_1 = require("../core/pakstrConfig");
10
+ const nostr_1 = require("../core/nostr");
11
+ const build_1 = require("./build");
12
+ const sign_1 = require("./sign");
13
+ const publish_1 = require("./publish");
14
+ /**
15
+ * `pakstr run` — build → sign → publish. Spec §5.5 / §6.
16
+ *
17
+ * Each step is atomic and fail-fast (§7). The signed APK at build.out is
18
+ * preserved on a publish failure; only ephemeral/derived material is cleaned
19
+ * up. `init` is NOT part of `run`.
20
+ */
21
+ async function runCommand(configPath, options = {}) {
22
+ // Step 1: load & validate config, pre-validate nsecs.
23
+ const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
24
+ // Verify PAKSTR_NSEC present (fail fast before any work).
25
+ (0, nostr_1.requireSigningNsec)(process.env);
26
+ // Pre-validate the publish nsec if publishing is enabled (§6 step 1).
27
+ if (config.publish.zapstoreEnabled) {
28
+ if (!(0, nostr_1.isPublishNsecPresent)(config.publish.publishKey, process.env)) {
29
+ const envVar = config.publish.publishKey === undefined
30
+ ? "PAKSTR_NSEC"
31
+ : config.publish.publishKey === null
32
+ ? "PAKSTR_PUBLISH_NSEC"
33
+ : config.publish.publishKey;
34
+ throw new Error(`${envVar} is required for publishing and must not be empty ` +
35
+ `(publish.zapstore.enabled is true). Pre-validation failed before any build work.`);
36
+ }
37
+ }
38
+ console.log("\n🚀 pakstr run");
39
+ console.log("App:", config.app.appName, `(${config.app.appId})`);
40
+ console.log("Version:", config.app.versionName, `(${config.app.versionCode})`);
41
+ console.log("Web:", config.build.web);
42
+ console.log("Out:", path_1.default.resolve(config.build.out));
43
+ console.log("Publish:", config.publish.zapstoreEnabled ? "enabled" : "disabled");
44
+ // Step 3: build the unsigned APK.
45
+ console.log("\n— Step 1/3: build —");
46
+ await (0, build_1.buildCommand)(config.configPath || undefined);
47
+ // Step 4: sign the APK.
48
+ console.log("\n— Step 2/3: sign —");
49
+ await (0, sign_1.signCommand)(config.configPath || undefined);
50
+ // Step 5: publish to Zap Store (unless disabled).
51
+ if (config.publish.zapstoreEnabled) {
52
+ console.log("\n— Step 3/3: publish —");
53
+ await (0, publish_1.publishCommand)(config.configPath || undefined, {
54
+ verify: options.verifyPublish,
55
+ outJson: options.publishOutJson,
56
+ });
57
+ }
58
+ else {
59
+ console.log("\nPublish disabled (publish.zapstore.enabled: false). Stopping after sign.");
60
+ }
61
+ // Step 6: cleanup. The signed APK at build.out is the artifact and is kept.
62
+ // Derived signing material is cleaned up inside the sign runner (tmpfs).
63
+ verifyNoLeakedKeystores(config);
64
+ console.log("\n✅ pakstr run complete");
65
+ console.log("Signed APK:", path_1.default.resolve(config.build.out));
66
+ }
67
+ /** Best-effort: ensure no derived .p12 keystore leaked onto disk (§7). */
68
+ function verifyNoLeakedKeystores(config) {
69
+ const buildDir = path_1.default.join(config.configDir, "build");
70
+ if (!fs_1.default.existsSync(buildDir))
71
+ return;
72
+ const walk = (dir) => {
73
+ const found = [];
74
+ for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
75
+ const p = path_1.default.join(dir, entry.name);
76
+ if (entry.isDirectory())
77
+ found.push(...walk(p));
78
+ else if (entry.name.endsWith(".p12") || entry.name.endsWith(".keystore"))
79
+ found.push(p);
80
+ }
81
+ return found;
82
+ };
83
+ const leaked = walk(buildDir);
84
+ if (leaked.length > 0) {
85
+ for (const f of leaked) {
86
+ try {
87
+ fs_1.default.rmSync(f, { force: true });
88
+ }
89
+ catch {
90
+ /* ignore */
91
+ }
92
+ }
93
+ throw new Error(`Derived keystore material leaked onto disk: ${leaked.join(", ")}`);
94
+ }
95
+ }
@@ -0,0 +1,41 @@
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.signCommand = signCommand;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const pakstrConfig_1 = require("../core/pakstrConfig");
10
+ const nostr_1 = require("../core/nostr");
11
+ const DockerSignRunner_1 = require("../runners/DockerSignRunner");
12
+ /**
13
+ * `pakstr sign` — sign the unsigned APK at `build.out` with the key derived
14
+ * from `PAKSTR_NSEC` + `app.appId`. Overwrites `build.out` with the signed APK.
15
+ * Spec §5.3.
16
+ */
17
+ async function signCommand(configPath) {
18
+ const config = (0, pakstrConfig_1.loadPakstrConfig)(configPath);
19
+ const unsignedApkPath = path_1.default.resolve(config.build.out);
20
+ if (!fs_1.default.existsSync(unsignedApkPath) || !fs_1.default.lstatSync(unsignedApkPath).isFile()) {
21
+ throw new Error(`Unsigned APK not found at ${unsignedApkPath}. Run \`pakstr build\` first.`);
22
+ }
23
+ const nsec = (0, nostr_1.requireSigningNsec)(process.env);
24
+ console.log("\n🔐 pakstr sign");
25
+ console.log("📦 Unsigned:", unsignedApkPath);
26
+ console.log("🔑 Deriving signing key from PAKSTR_NSEC +", config.app.appId);
27
+ const runner = new DockerSignRunner_1.DockerSignRunner();
28
+ const result = await runner.sign({
29
+ unsignedApkPath,
30
+ signedApkPath: unsignedApkPath,
31
+ appId: config.app.appId,
32
+ nsec: nsec.bytes,
33
+ });
34
+ // Write the signer certificate SHA-256 to a sidecar so `pakstr publish`/`run`
35
+ // can include it in the Zap Store asset event (apk_certificate_hash).
36
+ fs_1.default.writeFileSync(`${unsignedApkPath}.signer-sha256`, result.signerSha256, "utf8");
37
+ console.log("✅ SIGNED");
38
+ console.log("📦 Signed APK:", result.signedApkPath);
39
+ console.log("🧾 Signer certificate SHA-256:", result.signerSha256);
40
+ return result;
41
+ }
@@ -0,0 +1,70 @@
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.prepareAndroidProject = prepareAndroidProject;
7
+ exports.generateRuntimeConfig = generateRuntimeConfig;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const generatedProjectVerification_1 = require("../android/generatedProjectVerification");
11
+ const gradle_1 = require("../android/gradle");
12
+ const branding_1 = require("../android/branding");
13
+ const icon_1 = require("../android/icon");
14
+ const permissions_1 = require("../android/permissions");
15
+ const splash_1 = require("../android/splash");
16
+ /**
17
+ * Copy built web assets into the Android template and patch identity/branding.
18
+ * Spec §5.2. Creates `assets/www/` from `webDir`, patches Gradle, app name,
19
+ * icon, splash, and permissions, then verifies the generated project.
20
+ */
21
+ async function prepareAndroidProject(opts) {
22
+ const { androidRoot, webDir, app, configDir } = opts;
23
+ const assetsTarget = path_1.default.join(androidRoot, "app/src/main/assets/www");
24
+ if (!fs_1.default.existsSync(webDir)) {
25
+ throw new Error(`Web assets not found: ${webDir}`);
26
+ }
27
+ fs_1.default.rmSync(assetsTarget, { recursive: true, force: true });
28
+ fs_1.default.mkdirSync(assetsTarget, { recursive: true });
29
+ copyFolder(webDir, assetsTarget);
30
+ generateRuntimeConfig(assetsTarget);
31
+ (0, gradle_1.patchGradle)(androidRoot, app);
32
+ (0, branding_1.patchAppName)(androidRoot, app.appName);
33
+ if (app.icon) {
34
+ await (0, icon_1.patchIcon)(androidRoot, app.icon, app.backgroundColor ?? "#FFFFFF", configDir);
35
+ }
36
+ if (app.splash?.image) {
37
+ await (0, splash_1.patchSplash)(androidRoot, app.splash.image, app.splash.background ?? "#FFFFFF", configDir);
38
+ }
39
+ (0, permissions_1.patchPermissions)(androidRoot, app);
40
+ (0, generatedProjectVerification_1.verifyGeneratedAndroidProject)(androidRoot, {
41
+ applicationId: app.appId,
42
+ versionCode: app.versionCode,
43
+ versionName: app.versionName,
44
+ appLabel: app.appName,
45
+ });
46
+ }
47
+ /**
48
+ * Generate the runtime config written into the packaged web assets.
49
+ * Pakstr's config model has no runtime/debug fields, so this emits an empty
50
+ * config (kept for template compatibility).
51
+ */
52
+ function generateRuntimeConfig(webRoot) {
53
+ const runtime = {};
54
+ const jsContent = `window.PAKSTR_CONFIG = ${JSON.stringify(runtime, null, 2)};\n`;
55
+ fs_1.default.writeFileSync(path_1.default.join(webRoot, "pakstr-config.js"), jsContent.trim());
56
+ fs_1.default.writeFileSync(path_1.default.join(webRoot, "pakstr-runtime.json"), JSON.stringify(runtime, null, 2));
57
+ }
58
+ function copyFolder(src, dest) {
59
+ for (const entry of fs_1.default.readdirSync(src, { withFileTypes: true })) {
60
+ const srcPath = path_1.default.join(src, entry.name);
61
+ const destPath = path_1.default.join(dest, entry.name);
62
+ if (entry.isDirectory()) {
63
+ fs_1.default.mkdirSync(destPath, { recursive: true });
64
+ copyFolder(srcPath, destPath);
65
+ }
66
+ else {
67
+ fs_1.default.copyFileSync(srcPath, destPath);
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.asAndroidAppIdentity = asAndroidAppIdentity;
4
+ /** A `PakstrApp` is structurally an `AndroidAppIdentity`. */
5
+ function asAndroidAppIdentity(app) {
6
+ return app;
7
+ }
@@ -0,0 +1,122 @@
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.BlossomError = void 0;
7
+ exports.hashFile = hashFile;
8
+ exports.uploadToBlossom = uploadToBlossom;
9
+ exports.npubHexToBech32 = npubHexToBech32;
10
+ const crypto_1 = require("crypto");
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const nostr_1 = require("./nostr");
13
+ /**
14
+ * Blossom (BUD-02 / BUD-11) blob upload.
15
+ *
16
+ * Uploads a file to a Blossom server with a Nostr kind-24242 authorization
17
+ * event signed by the supplied secret key. Returns the blob descriptor
18
+ * (`url`, `sha256`, `size`, `type`).
19
+ *
20
+ * Reference: https://github.com/hzrd149/blossom (BUD-02 upload, BUD-11 auth).
21
+ */
22
+ const DEFAULT_CONTENT_TYPE = "application/vnd.android.package-archive";
23
+ const AUTH_TTL_SECONDS = 5 * 60;
24
+ class BlossomError extends Error {
25
+ status;
26
+ constructor(message, status) {
27
+ super(message);
28
+ this.status = status;
29
+ this.name = "BlossomError";
30
+ }
31
+ }
32
+ exports.BlossomError = BlossomError;
33
+ /** Compute the SHA-256 of a file (hex). */
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 };
49
+ }
50
+ async function uploadToBlossom(opts) {
51
+ const fetchFn = opts.fetchImpl ?? fetch;
52
+ const contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;
53
+ const serverUrl = opts.serverUrl.replace(/\/$/, "");
54
+ const { sha256, size } = hashFile(opts.filePath);
55
+ // 1. HEAD /<sha256> — skip upload if the blob already exists.
56
+ const head = await fetchFn(`${serverUrl}/${sha256}`, { method: "HEAD" });
57
+ if (head.status === 200) {
58
+ return {
59
+ url: `${serverUrl}/${sha256}`,
60
+ sha256,
61
+ size,
62
+ type: contentType,
63
+ };
64
+ }
65
+ // 2. Build + sign the kind-24242 Blossom auth event.
66
+ const npubHex = await (0, nostr_1.getNpubHex)(opts.secret);
67
+ const expiration = Math.floor(Date.now() / 1000) + AUTH_TTL_SECONDS;
68
+ const authEvent = await (0, nostr_1.signNostrEvent)({
69
+ kind: 24242,
70
+ created_at: Math.floor(Date.now() / 1000),
71
+ tags: [
72
+ ["t", "upload"],
73
+ ["x", sha256],
74
+ ["expiration", String(expiration)],
75
+ ],
76
+ content: `Upload ${sha256}`,
77
+ }, opts.secret);
78
+ // 3. PUT /upload with the signed auth event in the Authorization header.
79
+ const authHeader = "Nostr " + Buffer.from(JSON.stringify(authEvent)).toString("base64");
80
+ const body = fs_1.default.readFileSync(opts.filePath);
81
+ const res = await fetchFn(`${serverUrl}/upload`, {
82
+ method: "PUT",
83
+ headers: {
84
+ Authorization: authHeader,
85
+ "Content-Type": contentType,
86
+ "X-SHA-256": sha256,
87
+ "Content-Length": String(size),
88
+ },
89
+ body,
90
+ });
91
+ if (res.status !== 200 && res.status !== 201) {
92
+ let detail = "";
93
+ try {
94
+ detail = await res.text();
95
+ }
96
+ catch {
97
+ /* ignore */
98
+ }
99
+ throw new BlossomError(`Blossom upload failed: HTTP ${res.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`, res.status);
100
+ }
101
+ // 4. Parse the blob descriptor (fall back to a constructed one).
102
+ const text = await res.text();
103
+ try {
104
+ const descriptor = JSON.parse(text);
105
+ if (!descriptor.url)
106
+ descriptor.url = `${serverUrl}/${sha256}`;
107
+ if (!descriptor.sha256)
108
+ descriptor.sha256 = sha256;
109
+ if (!descriptor.size)
110
+ descriptor.size = size;
111
+ if (!descriptor.type)
112
+ descriptor.type = contentType;
113
+ return descriptor;
114
+ }
115
+ catch {
116
+ return { url: `${serverUrl}/${sha256}`, sha256, size, type: contentType };
117
+ }
118
+ }
119
+ /** Convenience: hex npub → bech32 npub string for display. */
120
+ function npubHexToBech32(npubHex) {
121
+ return (0, nostr_1.pubkeyHexToNpub)(npubHex);
122
+ }
@@ -0,0 +1,51 @@
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.loadDotEnv = loadDotEnv;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ /**
10
+ * Minimal `.env` loader for local development convenience.
11
+ *
12
+ * Parses a `.env` file (simple `KEY=VALUE` lines) and merges values into
13
+ * `process.env` WITHOUT overriding variables that are already set, so CI
14
+ * secret-manager values and explicit exports always win.
15
+ *
16
+ * This is purely a local-dev ergonomic: pakstr still reads secrets only from
17
+ * `process.env`. The `.env` file is gitignored (see `pakstr init`).
18
+ *
19
+ * Silently no-ops when the file is missing.
20
+ */
21
+ function loadDotEnv(dir = process.cwd()) {
22
+ const envPath = path_1.default.join(dir, ".env");
23
+ if (!fs_1.default.existsSync(envPath) || !fs_1.default.lstatSync(envPath).isFile()) {
24
+ return [];
25
+ }
26
+ const loaded = [];
27
+ const text = fs_1.default.readFileSync(envPath, "utf8");
28
+ for (const rawLine of text.split(/\r?\n/)) {
29
+ const line = rawLine.trim();
30
+ if (line.length === 0 || line.startsWith("#"))
31
+ continue;
32
+ const eq = line.indexOf("=");
33
+ if (eq <= 0)
34
+ continue;
35
+ const key = line.slice(0, eq).trim();
36
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
37
+ continue;
38
+ let value = line.slice(eq + 1).trim();
39
+ // Strip surrounding matching quotes.
40
+ if ((value.startsWith('"') && value.endsWith('"')) ||
41
+ (value.startsWith("'") && value.endsWith("'"))) {
42
+ value = value.slice(1, -1);
43
+ }
44
+ // Never override an existing env var (CI secrets / explicit exports win).
45
+ if (process.env[key] === undefined) {
46
+ process.env[key] = value;
47
+ loaded.push(key);
48
+ }
49
+ }
50
+ return loaded;
51
+ }
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NsecError = exports.NPUB_HRP = exports.NSEC_HRP = exports.PAKSTR_PUBLISH_NSEC_ENV = exports.PAKSTR_NSEC_ENV = void 0;
4
+ exports.decodeNsec = decodeNsec;
5
+ exports.encodeNsec = encodeNsec;
6
+ exports.encodeNpub = encodeNpub;
7
+ exports.requireSigningNsec = requireSigningNsec;
8
+ exports.resolvePublishNsec = resolvePublishNsec;
9
+ exports.isPublishNsecPresent = isPublishNsecPresent;
10
+ exports.signNostrEvent = signNostrEvent;
11
+ exports.getNpubHex = getNpubHex;
12
+ exports.pubkeyHexToNpub = pubkeyHexToNpub;
13
+ const base_1 = require("@scure/base");
14
+ const applesauce_signers_1 = require("applesauce-signers");
15
+ /**
16
+ * nsec + Nostr publish helpers.
17
+ *
18
+ * Uses applesauce (`PrivateKeySigner`) for secp256k1 event signing and
19
+ * `@scure/base` for Bech32 (nsec/npub) encode/decode. Never nostr-tools.
20
+ */
21
+ exports.PAKSTR_NSEC_ENV = "PAKSTR_NSEC";
22
+ exports.PAKSTR_PUBLISH_NSEC_ENV = "PAKSTR_PUBLISH_NSEC";
23
+ exports.NSEC_HRP = "nsec";
24
+ exports.NPUB_HRP = "npub";
25
+ const NSEC_PAYLOAD_BYTES = 32;
26
+ class NsecError extends Error {
27
+ envVar;
28
+ constructor(message, envVar) {
29
+ super(message);
30
+ this.envVar = envVar;
31
+ this.name = "NsecError";
32
+ }
33
+ }
34
+ exports.NsecError = NsecError;
35
+ /**
36
+ * Decode a Bech32 `nsec1…` string into 32 raw bytes.
37
+ * Validates: lowercase, HRP `nsec`, 32-byte payload, nonzero secp256k1 scalar.
38
+ */
39
+ function decodeNsec(value) {
40
+ if (typeof value !== "string" || value.length === 0) {
41
+ throw new NsecError("nsec is missing or empty");
42
+ }
43
+ if (value !== value.toLowerCase()) {
44
+ throw new NsecError("nsec must be lowercase bech32");
45
+ }
46
+ let decoded;
47
+ try {
48
+ decoded = base_1.bech32.decodeToBytes(value);
49
+ }
50
+ catch {
51
+ throw new NsecError("nsec is not valid bech32");
52
+ }
53
+ if (decoded.prefix !== exports.NSEC_HRP) {
54
+ throw new NsecError(`nsec must use the "${exports.NSEC_HRP}" human-readable part`);
55
+ }
56
+ const bytes = decoded.bytes;
57
+ if (bytes.length !== NSEC_PAYLOAD_BYTES) {
58
+ throw new NsecError("nsec payload must be exactly 32 bytes");
59
+ }
60
+ if (bytes.every(b => b === 0)) {
61
+ throw new NsecError("nsec must be a nonzero secret key");
62
+ }
63
+ // secp256k1 group order
64
+ const order = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141");
65
+ let scalar = 0n;
66
+ for (const b of bytes)
67
+ scalar = (scalar << 8n) | BigInt(b);
68
+ if (scalar === 0n || scalar >= order) {
69
+ throw new NsecError("nsec is not a valid secp256k1 private scalar");
70
+ }
71
+ return bytes;
72
+ }
73
+ /** Encode 32 raw bytes as a lowercase bech32 `nsec1…` string. */
74
+ function encodeNsec(bytes) {
75
+ if (bytes.length !== NSEC_PAYLOAD_BYTES) {
76
+ throw new NsecError("secret key must be exactly 32 bytes");
77
+ }
78
+ return base_1.bech32.encodeFromBytes(exports.NSEC_HRP, bytes);
79
+ }
80
+ /** Encode 32 raw pubkey bytes as `npub1…`. */
81
+ function encodeNpub(pubkey) {
82
+ return base_1.bech32.encodeFromBytes(exports.NPUB_HRP, pubkey);
83
+ }
84
+ /**
85
+ * Read and decode the signing nsec from `PAKSTR_NSEC`. Fails fast with a
86
+ * clear message if unset/empty/malformed. Never echoes the nsec value.
87
+ */
88
+ function requireSigningNsec(env = process.env) {
89
+ const value = env[exports.PAKSTR_NSEC_ENV];
90
+ if (value === undefined || value.length === 0) {
91
+ throw new NsecError(`${exports.PAKSTR_NSEC_ENV} is required and must not be empty`, exports.PAKSTR_NSEC_ENV);
92
+ }
93
+ const bytes = decodeNsec(value);
94
+ return { bytes, envVar: exports.PAKSTR_NSEC_ENV };
95
+ }
96
+ /**
97
+ * Resolve the publish nsec per SPEC §4.1.
98
+ *
99
+ * @param publishKey - value of `publish.publishKey` from pakstr.yaml:
100
+ * undefined → omitted entirely → reuse PAKSTR_NSEC;
101
+ * string (explicit) → read env var named by that string;
102
+ * null (bare key) → read PAKSTR_PUBLISH_NSEC.
103
+ * @param env - environment (defaults to process.env).
104
+ *
105
+ * Returns the resolved env-var name and decoded secret. Throws NsecError
106
+ * (fail fast) if the resolved env var is unset/empty/malformed.
107
+ */
108
+ function resolvePublishNsec(publishKey, env = process.env) {
109
+ if (publishKey === undefined) {
110
+ return requireSigningNsec(env);
111
+ }
112
+ const envVar = publishKey === null ? exports.PAKSTR_PUBLISH_NSEC_ENV : publishKey;
113
+ const value = env[envVar];
114
+ if (value === undefined || value.length === 0) {
115
+ throw new NsecError(`${envVar} is required for publishing and must not be empty`, envVar);
116
+ }
117
+ const bytes = decodeNsec(value);
118
+ return { bytes, envVar };
119
+ }
120
+ /** Only checks whether the resolved publish nsec env var is present & non-empty. */
121
+ function isPublishNsecPresent(publishKey, env = process.env) {
122
+ if (publishKey === undefined) {
123
+ const v = env[exports.PAKSTR_NSEC_ENV];
124
+ return v !== undefined && v.length > 0;
125
+ }
126
+ const envVar = publishKey === null ? exports.PAKSTR_PUBLISH_NSEC_ENV : publishKey;
127
+ const v = env[envVar];
128
+ return v !== undefined && v.length > 0;
129
+ }
130
+ /** Sign a Nostr event with a secret key (32 raw bytes). Uses applesauce. */
131
+ async function signNostrEvent(template, secret) {
132
+ const signer = new applesauce_signers_1.PrivateKeySigner(secret);
133
+ const event = await signer.signEvent({
134
+ kind: template.kind,
135
+ created_at: template.created_at,
136
+ tags: template.tags,
137
+ content: template.content,
138
+ });
139
+ return event;
140
+ }
141
+ /** Derive the hex npub (publisher identity) for a secret key. */
142
+ async function getNpubHex(secret) {
143
+ const signer = new applesauce_signers_1.PrivateKeySigner(secret);
144
+ return await signer.getPublicKey();
145
+ }
146
+ /** Convert a hex pubkey to bech32 npub. */
147
+ function pubkeyHexToNpub(hexPubkey) {
148
+ if (!/^[0-9a-f]{64}$/.test(hexPubkey)) {
149
+ throw new NsecError("pubkey must be 64 hex characters");
150
+ }
151
+ const bytes = Buffer.from(hexPubkey, "hex");
152
+ return encodeNpub(bytes);
153
+ }