nostr-bray 2.3.0 → 2.4.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/dist/bunker.js CHANGED
@@ -86,11 +86,33 @@ export function startBunker(opts) {
86
86
  case 'get_public_key':
87
87
  result = ctx.activePublicKeyHex;
88
88
  break;
89
- case 'sign_event': {
90
- const template = JSON.parse(request.params[0]);
89
+ // `sign_event_compact` is the Heartwood dialect, and Bray speaks it so a
90
+ // client does not have to keep two code paths or waste a round trip
91
+ // discovering it is unsupported.
92
+ //
93
+ // Two differences from NIP-46, both about not moving the event around as
94
+ // a string. params[0] may be the event OBJECT rather than a stringified
95
+ // event, which spares a signer an unescape pass costing twice the content
96
+ // in one allocation; and the compact reply returns only what the client
97
+ // cannot work out itself. On a hardware signer those two copies are the
98
+ // difference between a 12 KB and an 18 KB ceiling. Bray has no such
99
+ // limit, but matching the dialect keeps clients simple.
100
+ case 'sign_event':
101
+ case 'sign_event_compact': {
102
+ // Declared string[], but that was always a claim about the client
103
+ // rather than a guarantee, and here it is genuinely either.
104
+ const raw = request.params[0];
105
+ const template = (typeof raw === 'string' ? JSON.parse(raw) : raw);
91
106
  const sign = ctx.getSigningFunction();
92
107
  const signed = await sign(template);
93
- result = JSON.stringify(signed);
108
+ result = request.method === 'sign_event_compact'
109
+ ? JSON.stringify({
110
+ id: signed.id,
111
+ pubkey: signed.pubkey,
112
+ created_at: signed.created_at,
113
+ sig: signed.sig,
114
+ })
115
+ : JSON.stringify(signed);
94
116
  break;
95
117
  }
96
118
  case 'nip04_encrypt': {
@@ -18,11 +18,25 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool) {
18
18
  case 'persona':
19
19
  out(await ctx.derivePersona(req(1, 'persona <name> [index]'), parseInt(cmdArgs[2] ?? '0', 10)));
20
20
  break;
21
- case 'switch':
22
- await ctx.switch(req(1, 'switch <target> [index]'), cmdArgs[2] ? parseInt(cmdArgs[2], 10) : undefined);
23
- console.error(`Now signing as ${ctx.activeNpub}`);
21
+ case 'switch': {
22
+ const target = req(1, 'switch <target> [index]');
23
+ await ctx.switch(target, cmdArgs[2] ? parseInt(cmdArgs[2], 10) : undefined);
24
+ // Name the derivation path, not just the npub. A bare name means the
25
+ // persona path, so `switch work` and `derive work` are different keys;
26
+ // without the path on screen the difference is invisible until something
27
+ // has been signed by the wrong identity.
28
+ console.error(`Now signing as ${ctx.activeNpub} (${ctx.activePurpose})`);
29
+ if (target !== 'master' && !target.includes(':') && ctx.activePurpose === `persona/${target}`) {
30
+ console.error(`Note: "${target}" resolved to the persona path. The raw purpose ` +
31
+ `"${target}" is a different key — see \`derive ${target}\`.`);
32
+ }
33
+ // The CLI is one process per command, so this applies to the rest of this
34
+ // invocation only. Say so rather than implying a saved default.
35
+ console.error('This switch applies to this command only; use --key or --bunker to sign as');
36
+ console.error('another identity in a later command.');
24
37
  console.log(ctx.activeNpub);
25
38
  break;
39
+ }
26
40
  case 'prove':
27
41
  out(await handleIdentityProve(ctx, { mode: (cmdArgs[1] === 'full' ? 'full' : 'blind') }));
28
42
  break;
package/dist/context.d.ts CHANGED
@@ -13,6 +13,8 @@ export declare class IdentityContext implements ExtendedSigningContext {
13
13
  constructor(secretKey: string, format: 'nsec' | 'hex' | 'mnemonic', opts?: ContextOptions);
14
14
  /** Current active identity's npub (bech32) */
15
15
  get activeNpub(): string;
16
+ /** Derivation path of the current active identity, for reporting to a human */
17
+ get activePurpose(): string;
16
18
  /** Current active identity's hex public key — use this in relay filters */
17
19
  get activePublicKeyHex(): string;
18
20
  /** Derive a child identity by purpose and index */
package/dist/context.js CHANGED
@@ -55,6 +55,10 @@ export class IdentityContext {
55
55
  get activeNpub() {
56
56
  return this.activeEntry.identity.npub;
57
57
  }
58
+ /** Derivation path of the current active identity, for reporting to a human */
59
+ get activePurpose() {
60
+ return this.activeEntry.purpose;
61
+ }
58
62
  /** Current active identity's hex public key — use this in relay filters */
59
63
  get activePublicKeyHex() {
60
64
  return Buffer.from(this.activeEntry.identity.publicKey).toString('hex');
@@ -8,6 +8,7 @@ export interface BlobDescriptor {
8
8
  type?: string;
9
9
  uploaded?: number;
10
10
  }
11
+ export declare function detectContentType(body: Uint8Array): string | undefined;
11
12
  /** Upload a file to a blossom media server */
12
13
  export declare function handleBlossomUpload(ctx: SigningContext, args: {
13
14
  server: string;
@@ -5,6 +5,36 @@ import { validatePublicUrl } from '../validation.js';
5
5
  // fetch. Keeps an attacker-chosen source from amplifying nostr-bray into a
6
6
  // megabyte-scale Blossom relay.
7
7
  const MAX_UPLOAD = 100 * 1024 * 1024;
8
+ // Blossom servers sniff the body and reject a mismatched Content-Type, so
9
+ // `application/octet-stream` fails for any format they recognise. Detect from
10
+ // the leading bytes rather than the file name: the bytes are what the server
11
+ // checks, and an uploader should not be able to mislabel a blob by renaming it.
12
+ const MAGIC = [
13
+ { type: 'image/png', test: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
14
+ { type: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
15
+ { type: 'image/gif', test: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 },
16
+ {
17
+ type: 'image/webp',
18
+ test: (b) => b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
19
+ b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
20
+ },
21
+ { type: 'application/pdf', test: (b) => b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 },
22
+ { type: 'image/svg+xml', test: (b) => Buffer.from(b.subarray(0, 256)).toString('utf8').includes('<svg') },
23
+ ];
24
+ export function detectContentType(body) {
25
+ if (body.byteLength < 4)
26
+ return undefined;
27
+ for (const { type, test } of MAGIC) {
28
+ try {
29
+ if (test(body))
30
+ return type;
31
+ }
32
+ catch {
33
+ // a short or odd buffer simply does not match this signature
34
+ }
35
+ }
36
+ return undefined;
37
+ }
8
38
  /** Upload a file to a blossom media server */
9
39
  export async function handleBlossomUpload(ctx, args) {
10
40
  validatePublicUrl(args.server);
@@ -44,7 +74,7 @@ export async function handleBlossomUpload(ctx, args) {
44
74
  method: 'PUT',
45
75
  headers: {
46
76
  Authorization: authHeader,
47
- 'Content-Type': args.contentType ?? 'application/octet-stream',
77
+ 'Content-Type': args.contentType ?? detectContentType(body) ?? 'application/octet-stream',
48
78
  },
49
79
  body: Buffer.from(body),
50
80
  signal: AbortSignal.timeout(30_000),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "mcpName": "dev.forgesworn/bray",
5
5
  "description": "Trust-aware Nostr for AI and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
6
6
  "type": "module",