Compare commits
6 commits
e39eaa632d
...
662dd21a60
| Author | SHA1 | Date | |
|---|---|---|---|
| 662dd21a60 | |||
| ccfde02d70 | |||
| 053357899d | |||
| 5e77de1202 | |||
| 0a510b7f9a | |||
| 8caf856ab2 |
6 changed files with 136 additions and 29 deletions
16
Dockerfile
16
Dockerfile
|
|
@ -39,10 +39,20 @@ RUN npm install -g pnpm@9
|
||||||
# Copy built files from the build stage
|
# Copy built files from the build stage
|
||||||
COPY --from=build /app .
|
COPY --from=build /app .
|
||||||
|
|
||||||
# Install only runtime dependencies (pnpm respects the workspace protocol)
|
# Install all dependencies (including devDeps). The prisma CLI lives in
|
||||||
RUN pnpm install --prod --no-frozen-lockfile
|
# devDependencies but scripts/start.js invokes `prisma migrate deploy`
|
||||||
|
# at boot, so it must be available at runtime. Dropping --prod adds the
|
||||||
|
# CLI tooling to the runtime image — a modest size cost for the
|
||||||
|
# correctness of the migration step.
|
||||||
|
RUN pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENTRYPOINT [ "node", "./dist/index.js" ]
|
# Run via scripts/start.js so `prisma migrate deploy` applies pending
|
||||||
|
# migrations before the daemon spawns. The upstream Dockerfile invokes
|
||||||
|
# ./dist/index.js directly, which silently bypasses the migration step
|
||||||
|
# and leaves the SQLite db empty on first boot — every command that
|
||||||
|
# touches Policy/KeyUser/Token/etc. then throws "table does not exist."
|
||||||
|
# Caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27.
|
||||||
|
ENTRYPOINT [ "node", "./scripts/start.js" ]
|
||||||
CMD ["start"]
|
CMD ["start"]
|
||||||
|
|
|
||||||
38
package.nix
38
package.nix
|
|
@ -13,12 +13,17 @@
|
||||||
}:
|
}:
|
||||||
|
|
||||||
let
|
let
|
||||||
# package.json pins `@nostr-dev-kit/ndk: "workspace:*"` but the lockfile
|
# Fork commit `06272c8` ("pin @nostr-dev-kit/ndk to 2.8.1 instead of
|
||||||
# resolves `^2.8.1`. With --frozen-lockfile pnpm refuses the mismatch,
|
# workspace:*") changed package.json to a pinned `"2.8.1"`, but the
|
||||||
# so rewrite the spec to match the lockfile.
|
# pnpm-lock.yaml still expresses the spec as `"^2.8.1"` (the way
|
||||||
|
# `pnpm add` originally generated it). pnpm with --frozen-lockfile
|
||||||
|
# rejects that mismatch. Patching package.json to use the caret form
|
||||||
|
# is non-semantic (2.8.1 is still the resolved version) and aligns
|
||||||
|
# both files. Same fix the Dockerfile-side already handles via
|
||||||
|
# `--no-frozen-lockfile`; in nix we prefer frozen + a targeted patch.
|
||||||
patchNdk = ''
|
patchNdk = ''
|
||||||
substituteInPlace package.json \
|
substituteInPlace package.json \
|
||||||
--replace-fail '"@nostr-dev-kit/ndk": "workspace:*"' \
|
--replace-fail '"@nostr-dev-kit/ndk": "2.8.1"' \
|
||||||
'"@nostr-dev-kit/ndk": "^2.8.1"'
|
'"@nostr-dev-kit/ndk": "^2.8.1"'
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|
@ -77,7 +82,12 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
pnpm prisma generate
|
pnpm prisma generate
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
pnpm prune --prod --ignore-scripts
|
# Do NOT `pnpm prune --prod` here — the prisma CLI lives in
|
||||||
|
# devDependencies and `scripts/start.js` invokes it at boot via
|
||||||
|
# `npx prisma migrate deploy`. Without the CLI, the migration step
|
||||||
|
# silently fails (npx falls back to downloading prisma fresh, which
|
||||||
|
# OOMs on most containers) and the SQLite db stays empty. See
|
||||||
|
# `aiolabs/nsecbunkerd#7` diagnosis 2026-05-27.
|
||||||
find node_modules -xtype l -delete
|
find node_modules -xtype l -delete
|
||||||
|
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
|
|
@ -87,14 +97,24 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
|
|
||||||
mkdir -p $out/{bin,share/nsecbunkerd}
|
mkdir -p $out/{bin,share/nsecbunkerd}
|
||||||
cp -r dist node_modules prisma templates package.json \
|
# scripts/ MUST be copied — it contains the start.js launcher that
|
||||||
|
# runs `prisma migrate deploy` before spawning the daemon. The
|
||||||
|
# upstream packaging (and the upstream Dockerfile) bypassed this by
|
||||||
|
# invoking dist/index.js directly, leaving migrations unapplied.
|
||||||
|
cp -r dist node_modules prisma scripts templates package.json \
|
||||||
$out/share/nsecbunkerd/
|
$out/share/nsecbunkerd/
|
||||||
|
|
||||||
|
# Wrapper invokes scripts/start.js, which runs `prisma migrate deploy`
|
||||||
|
# then spawns dist/index.js. start.js resolves sibling paths from
|
||||||
|
# __dirname, so the caller (systemd unit, docker compose, etc.) can
|
||||||
|
# set its own WorkingDirectory for the writable state dir without
|
||||||
|
# interfering with how the launcher finds its own package files.
|
||||||
|
# NSEC_BUNKER_CONFIG_DIR can override the config directory location;
|
||||||
|
# by default it's `./config` relative to cwd.
|
||||||
makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunkerd \
|
makeWrapper ${lib.getExe nodejs_20} $out/bin/nsecbunkerd \
|
||||||
--chdir $out/share/nsecbunkerd \
|
--add-flags $out/share/nsecbunkerd/scripts/start.js \
|
||||||
--add-flags $out/share/nsecbunkerd/dist/index.js \
|
|
||||||
--set NODE_ENV production \
|
--set NODE_ENV production \
|
||||||
--prefix PATH : ${lib.makeBinPath [ openssl ]} \
|
--prefix PATH : ${lib.makeBinPath [ openssl nodejs_20 ]} \
|
||||||
${
|
${
|
||||||
lib.concatStringsSep " \\\n " (
|
lib.concatStringsSep " \\\n " (
|
||||||
lib.mapAttrsToList (n: v: "--set ${n} ${lib.escapeShellArg v}") prismaEnv
|
lib.mapAttrsToList (n: v: "--set ${n} ${lib.escapeShellArg v}") prismaEnv
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,32 @@
|
||||||
const { execSync, spawn } = require('child_process');
|
const { execSync, spawn } = require('child_process');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Resolve sibling paths from this script's location so the launcher
|
||||||
|
// works whether cwd is /app (docker), the nix store, or a writable
|
||||||
|
// state dir set by systemd's WorkingDirectory. The prisma CLI and
|
||||||
|
// dist/index.js live alongside this file in `<pkg>/share/nsecbunkerd/`
|
||||||
|
// (nix) or `/app/` (docker). The migration-side env knobs:
|
||||||
|
// NSEC_BUNKER_CONFIG_DIR — directory holding nsecbunker.{json,db};
|
||||||
|
// defaults to ./config relative to cwd.
|
||||||
|
// DATABASE_URL — prisma's source of truth for the sqlite
|
||||||
|
// path; honor whatever the caller set.
|
||||||
|
const pkgRoot = path.resolve(__dirname, '..');
|
||||||
|
const configDir = process.env.NSEC_BUNKER_CONFIG_DIR || path.resolve(process.cwd(), 'config');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Running migrations`);
|
console.log(`Running migrations`);
|
||||||
// check if config folder exists
|
if (!fs.existsSync(configDir)) {
|
||||||
if (!fs.existsSync('./config')) {
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
execSync(`mkdir config`);
|
|
||||||
}
|
}
|
||||||
execSync('npm run prisma:migrate');
|
execSync('npm run prisma:migrate', { cwd: pkgRoot, stdio: 'inherit' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
// Handle any potential migration errors here
|
// Handle any potential migration errors here
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const childProcess = spawn('node', ['./dist/index.js', ...args], {
|
const childProcess = spawn('node', [path.join(pkgRoot, 'dist/index.js'), ...args], {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Hexpubkey, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||||
import AdminInterface from "..";
|
import AdminInterface from "..";
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { setupSkeletonProfile } from "../../lib/profile";
|
import { setupSkeletonProfile } from "../../lib/profile";
|
||||||
|
|
@ -136,7 +136,7 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen
|
* This is where the real work of creating the private key, wallet, nip-05, granting access, etc happen — pragma: allowlist secret
|
||||||
*/
|
*/
|
||||||
export async function createAccountReal(
|
export async function createAccountReal(
|
||||||
admin: AdminInterface,
|
admin: AdminInterface,
|
||||||
|
|
@ -209,11 +209,18 @@ export async function createAccountReal(
|
||||||
// access it without having to go through an approval flow
|
// access it without having to go through an approval flow
|
||||||
await grantPermissions(req, keyName);
|
await grantPermissions(req, keyName);
|
||||||
|
|
||||||
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnectAdmin);
|
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — it resolves
|
||||||
|
// to `undefined` and sendResponse defaults to NDKKind.NostrConnect
|
||||||
|
// (24133), sending the response on the wrong channel. Mirror the
|
||||||
|
// request's kind so the response goes back on the same channel the
|
||||||
|
// client subscribed for. Filed as part of aiolabs/nsecbunkerd#7
|
||||||
|
// diagnosis 2026-05-27.
|
||||||
|
const originalKind = req.event.kind!;
|
||||||
|
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, originalKind);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.trace('error', e);
|
console.trace('error', e);
|
||||||
return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin,
|
const originalKind = req.event.kind!;
|
||||||
e.message);
|
return admin.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,19 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
|
||||||
|
|
||||||
if (!clientName || !policyId) throw new Error("Invalid params");
|
if (!clientName || !policyId) throw new Error("Invalid params");
|
||||||
|
|
||||||
const policy = await prisma.policy.findUnique({ where: { id: parseInt(policyId) }, include: { rules: true } });
|
const policyIdInt = parseInt(policyId);
|
||||||
|
const policy = await prisma.policy.findUnique({ where: { id: policyIdInt }, include: { rules: true } });
|
||||||
|
|
||||||
if (!policy) throw new Error("Policy not found");
|
if (!policy) throw new Error("Policy not found");
|
||||||
|
|
||||||
console.log({clientName, policy, durationInHours});
|
console.log({clientName, policy, durationInHours});
|
||||||
|
|
||||||
const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
|
const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||||
|
// policyId must be Int per the Prisma schema (Token.policyId references
|
||||||
|
// Policy.id which is autoincrement Int). Upstream passes the raw string
|
||||||
|
// from the wire — caught during aiolabs/nsecbunkerd#7 diagnosis 2026-05-27.
|
||||||
const data: any = {
|
const data: any = {
|
||||||
keyName, clientName, policyId,
|
keyName, clientName, policyId: policyIdInt,
|
||||||
createdBy: req.pubkey,
|
createdBy: req.pubkey,
|
||||||
token
|
token
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,28 @@ class AdminInterface {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ndk.pool.on('relay:connect', () => console.log('✅ nsecBunker Admin Interface ready'));
|
const debugTransport = process.env.NSEC_BUNKER_DEBUG_TRANSPORT === '1';
|
||||||
|
|
||||||
|
// Per-relay publish-status logging for diagnosing aiolabs/nsecbunkerd#7.
|
||||||
|
// NDKNostrRpc.sendResponse calls event.publish() and discards the
|
||||||
|
// returned Set<NDKRelay>, so a silent outbox-drop is invisible without
|
||||||
|
// hooking the underlying per-relay events. Gated by env flag so
|
||||||
|
// production deployments stay quiet.
|
||||||
|
const attachRelayLogging = (relay: any) => {
|
||||||
|
relay.on('published', (event: NDKEvent) => {
|
||||||
|
console.log(`📤 PUBLISHED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)}`);
|
||||||
|
});
|
||||||
|
relay.on('publish:failed', (event: NDKEvent, err: any) => {
|
||||||
|
console.log(`❌ PUBLISH_FAILED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)} err=${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ndk.pool.on('relay:connect', (relay: any) => {
|
||||||
|
console.log('✅ nsecBunker Admin Interface ready');
|
||||||
|
if (debugTransport) attachRelayLogging(relay);
|
||||||
|
});
|
||||||
this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected'));
|
this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected'));
|
||||||
|
|
||||||
this.ndk.connect(2500).then(() => {
|
this.ndk.connect(2500).then(() => {
|
||||||
// connect for whitelisted admins
|
// connect for whitelisted admins
|
||||||
this.rpc.subscribe({
|
this.rpc.subscribe({
|
||||||
|
|
@ -120,7 +140,33 @@ class AdminInterface {
|
||||||
"#p": [this.signerUser!.pubkey]
|
"#p": [this.signerUser!.pubkey]
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rpc.on('request', (req) => this.handleRequest(req));
|
// Attach per-relay logging to relays that connected before our
|
||||||
|
// 'relay:connect' listener was registered above (NDK can connect
|
||||||
|
// synchronously inside .connect() under some paths).
|
||||||
|
if (debugTransport) {
|
||||||
|
this.ndk.pool.relays.forEach((relay: any) => attachRelayLogging(relay));
|
||||||
|
|
||||||
|
// Wrap sendResponse to log id + kind + elapsed time so we
|
||||||
|
// can correlate REQUEST_IN → RESPONSE_SENT → PUBLISHED.
|
||||||
|
const originalSendResponse = this.rpc.sendResponse.bind(this.rpc);
|
||||||
|
this.rpc.sendResponse = async (id: string, remotePubkey: string, result: string, kind?: number, error?: string) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await originalSendResponse(id, remotePubkey, result, kind, error);
|
||||||
|
console.log(`📨 RESPONSE_SENT id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} elapsed=${Date.now()-start}ms`);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`❌ RESPONSE_SEND_FAILED id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} err=${e?.message ?? e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rpc.on('request', (req) => {
|
||||||
|
if (debugTransport) {
|
||||||
|
console.log(`📥 REQUEST_IN method=${req.method} id=${req.id} from=${req.pubkey?.slice(0,8)} kind=${req.event?.kind}`);
|
||||||
|
}
|
||||||
|
this.handleRequest(req);
|
||||||
|
});
|
||||||
|
|
||||||
// pingOrDie disabled — NDK 2.8.1 outbox model doesn't echo
|
// pingOrDie disabled — NDK 2.8.1 outbox model doesn't echo
|
||||||
// self-published events back through subscriptions on
|
// self-published events back through subscriptions on
|
||||||
|
|
@ -163,7 +209,15 @@ class AdminInterface {
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params);
|
debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params);
|
||||||
return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, err?.message);
|
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — using it
|
||||||
|
// makes sendResponse fall through to its default of 24133, which
|
||||||
|
// sends the error on a different channel than the request came in
|
||||||
|
// on. Mirror req.event.kind so the response goes back where the
|
||||||
|
// client is listening. Filed as part of aiolabs/nsecbunkerd#7
|
||||||
|
// diagnosis 2026-05-27.
|
||||||
|
const originalKind = req.event.kind!;
|
||||||
|
console.log(`⚠️ HANDLE_REQUEST_ERROR method=${req.method} id=${req.id} kind=${originalKind} err=${err?.message ?? err}`);
|
||||||
|
return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue