freshjots 1.0.2 → 1.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54b9c5dc0f9e2eef0978dc53579737fe6cf52fefee6b4105c9a8903761c7817b
4
- data.tar.gz: 2c40157c50f2ad882ede4486762b90f0cb35c715922e66c4533715100aafdddc
3
+ metadata.gz: 3916dec8cefe696476c18a0a28aba36a1657721082098eef1929129c05355b84
4
+ data.tar.gz: 94b852d213f92017d897fe309efd21f9ecf60c6d398e0cbfe6c37af0f11219b0
5
5
  SHA512:
6
- metadata.gz: bf52b3c3da728a65071d7bcd6cd5c4cac197c17f293747ff870d7299365be80b1007c0787417e0fc2430decd389956ca518a2c4950f53c8b78364dc2f772fad7
7
- data.tar.gz: 442c1eccc0d157c7c0cb48b304ddc75ac6c23099f1723ad1623c3dbcf658887ff9f7e9a82412e47e4a90f2f47914d9104a25ce4677e4dfdf2168267943909bb1
6
+ metadata.gz: 8c650cb3dfdb4ff2c3d4680652e9fa8ec82cfd2fa5f6605fe9e33a3f67b3b6149d46ac5b3f6aada3cb2b3a7c4c8e77d2e4ebf98c122a5957439ce2205001aa9b
7
+ data.tar.gz: 1ee7ec3cace2bf20ce328f27d9ffb1ed91348eabb5fc3bf1354a9c21f09cd06707d22ca8f9beeb8dfc42b23a39bf8c873d19c0d7bc48af8089f149d659ede7e9
data/README.md CHANGED
@@ -44,13 +44,53 @@ end
44
44
  created = client.create(title: "Research 2026 Q2", body: "Initial outline.")
45
45
  puts created[:filename] # server-derived stream name
46
46
 
47
- # Organize: move into a folder (by id or name), delete (by id or filename), list folders.
47
+ # Update a note's fields (only the keys you pass change). By id or by filename:
48
+ client.update(42, title: "Q2 research", body: "Revised outline.")
49
+ client.set("cron-jobs-prod", folder: "Ops", deadline: 26) # metadata only, no body needed
50
+
51
+ # Create many notes in one atomic batch (up to 50 — all land or none do).
52
+ client.bulk([{ title: "a", plain_body: "1" }, { title: "b", plain_body: "2" }])
53
+
54
+ # Organize: move into a folder (by id or name), delete (by id or filename).
48
55
  client.move("cron-jobs-prod", folder: "Ops")
49
56
  client.delete("old-note")
57
+
58
+ # Folders: list, read one, create, rename, delete (its notes survive, un-foldered).
50
59
  client.folders.each { |f| puts "#{f[:id]}\t#{f[:name]}" }
60
+ ops = client.create_folder("Ops")
61
+ client.rename_folder(ops[:id], "Operations")
62
+ client.delete_folder(ops[:id])
63
+ ```
64
+
65
+ Client methods: `notes(sort:, folder_id:, limit:, offset:)`, `note(filename)`, `note_by_id(id)`, `create(title:, body:, client_encrypted:)`, `append(filename, text, client_encrypted:)`, `update(id, **fields)`, `set(filename, **fields)`, `bulk(notes)`, `delete(id_or_filename)`, `move(id_or_filename, folder:)`, `folders`, `folder(id)`, `create_folder(name)`, `rename_folder(id, name)`, and `delete_folder(id)`. Client-side crypto: `Freshjots.encrypt(text, passphrase)` / `Freshjots.decrypt(token, passphrase)` (see [Encryption](#encryption)). `note`/`note_by_id`/`create`/`update`/`set` and the single-folder methods return the hash directly (no `{ note: … }` / `{ folder: … }` wrapper); `notes` and `folders` return arrays, and `bulk` returns `{ created: [...] }`. For `update`/`set`, the fields are `title:`, `body:`, `folder:` (id or name — `root: true` un-folders), `deadline:`, `alert_email:`, `webhook_url:`, `webhook_secret:`; changing `title:` rewrites the body as a unit, so pass `body:` too. For `notes`, `sort` is `created|updated|appended` and `folder_id` may be a folder id or `"none"` (un-foldered only).
66
+
67
+ ## Encryption
68
+
69
+ Keep notes the server can't read: encrypt locally with your own passphrase,
70
+ store the ciphertext, decrypt locally on read. Built in on Ruby's stdlib
71
+ `openssl` (no gem dependencies), and interoperable with the JS and Python
72
+ clients.
73
+
74
+ ```ruby
75
+ require "freshjots"
76
+
77
+ client = Freshjots::Client.new
78
+ pw = ENV.fetch("FRESHJOTS_PASSPHRASE")
79
+
80
+ # Store an encrypted note: encrypt the body, flag it client_encrypted.
81
+ client.create(title: "Recovery codes", body: Freshjots.encrypt("1234-5678", pw), client_encrypted: true)
82
+
83
+ # Read it back and decrypt locally.
84
+ puts Freshjots.decrypt(client.note("recovery-codes")[:plain_body], pw)
51
85
  ```
52
86
 
53
- Client methods: `notes(sort:, folder_id:, limit:, offset:)`, `note(filename)`, `note_by_id(id)`, `create(title:, body:)`, `append(filename, text)`, `delete(id_or_filename)`, `move(id_or_filename, folder:)`, and `folders`. `note`/`note_by_id`/`create` return the note hash directly (no `{ note: … }` wrapper); `notes` and `folders` return arrays. For `notes`, `sort` is `created|updated|appended` and `folder_id` may be a folder id or `"none"` (un-foldered only).
87
+ The format is `fj1` (AES-256-CBC + HMAC-SHA256, PBKDF2-HMAC-SHA256), interoperable
88
+ with the JS, Python, MCP, and shell (`brew`) clients. You hold the only key —
89
+ Fresh Jots never receives it and **cannot recover the note if you lose it**, so
90
+ back the passphrase up somewhere safe. Encryption is per-note and personal-only
91
+ (not team notes); the title and metadata stay in the clear, so keep secrets out
92
+ of the title. `Freshjots.decrypt` raises `Freshjots::EncryptionError` on a wrong
93
+ passphrase. See <https://freshjots.com/encrypted-notes>.
54
94
 
55
95
  ## Errors
56
96
 
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "securerandom"
6
+
7
+ # Client-side encryption for Fresh Jots notes — format "fj1".
8
+ #
9
+ # Encrypt locally with your own passphrase; the server stores only the
10
+ # ciphertext and can never read it. Wire format:
11
+ #
12
+ # "fj1:" + base64( salt[16] | iv[16] | ciphertext | mac[32] )
13
+ #
14
+ # A single PBKDF2-HMAC-SHA256 pass (210_000 iterations) derives 64 bytes from
15
+ # the passphrase and salt: the first 32 are the AES-256-CBC key, the last 32 the
16
+ # HMAC-SHA256 key. The note is AES-256-CBC encrypted, then authenticated
17
+ # encrypt-then-MAC over iv|ciphertext; decryption verifies the MAC before
18
+ # decrypting. The output is a single line (base64 carries no newlines), so it
19
+ # survives the server's newline append separator. The format is identical
20
+ # across the Fresh Jots JS, Python, Ruby, and shell clients: a note encrypted
21
+ # by one decrypts with the others. (CBC+HMAC, not GCM, because it is the one
22
+ # authenticated construction every client — including the bash CLI, whose
23
+ # openssl refuses AEAD — can implement identically.) Uses only Ruby's stdlib
24
+ # openssl (no gem deps).
25
+ module Freshjots
26
+ class EncryptionError < StandardError; end
27
+
28
+ FJ_PREFIX = "fj1:"
29
+ FJ_ITERATIONS = 210_000
30
+ FJ_SALT_LEN = 16
31
+ FJ_IV_LEN = 16
32
+ FJ_MAC_LEN = 32
33
+
34
+ # True if the text carries the Fresh Jots ciphertext prefix ("fj1:"). A
35
+ # declaration of shape, not a guarantee it decrypts.
36
+ def self.encrypted?(text)
37
+ text.is_a?(String) && text.start_with?(FJ_PREFIX)
38
+ end
39
+
40
+ # Encrypt a string with a passphrase; returns an "fj1:" token.
41
+ def self.encrypt(plaintext, passphrase)
42
+ raise ArgumentError, "encrypt requires a passphrase" if passphrase.nil? || passphrase.empty?
43
+
44
+ salt = SecureRandom.random_bytes(FJ_SALT_LEN)
45
+ iv = SecureRandom.random_bytes(FJ_IV_LEN)
46
+ enc_key, mac_key = fj_derive_keys(passphrase, salt)
47
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
48
+ cipher.encrypt
49
+ cipher.key = enc_key
50
+ cipher.iv = iv
51
+ ciphertext = cipher.update(plaintext.to_s) + cipher.final
52
+ mac = OpenSSL::HMAC.digest("SHA256", mac_key, iv + ciphertext)
53
+ FJ_PREFIX + Base64.strict_encode64(salt + iv + ciphertext + mac)
54
+ end
55
+
56
+ # Decrypt an "fj1:" token back to its plaintext. Raises EncryptionError on a
57
+ # malformed token, a wrong passphrase, or tampering.
58
+ def self.decrypt(token, passphrase)
59
+ raise ArgumentError, "decrypt requires a passphrase" if passphrase.nil? || passphrase.empty?
60
+ raise EncryptionError, "not a Fresh Jots ciphertext (missing 'fj1:' prefix)" unless encrypted?(token)
61
+
62
+ blob =
63
+ begin
64
+ Base64.strict_decode64(token[FJ_PREFIX.length..])
65
+ rescue ArgumentError
66
+ raise EncryptionError, "ciphertext is not valid base64"
67
+ end
68
+ if blob.bytesize < FJ_SALT_LEN + FJ_IV_LEN + FJ_MAC_LEN + 16
69
+ raise EncryptionError, "ciphertext is truncated or corrupted"
70
+ end
71
+
72
+ salt = blob.byteslice(0, FJ_SALT_LEN)
73
+ iv = blob.byteslice(FJ_SALT_LEN, FJ_IV_LEN)
74
+ mac = blob.byteslice(blob.bytesize - FJ_MAC_LEN, FJ_MAC_LEN)
75
+ ct_len = blob.bytesize - FJ_SALT_LEN - FJ_IV_LEN - FJ_MAC_LEN
76
+ ct = blob.byteslice(FJ_SALT_LEN + FJ_IV_LEN, ct_len)
77
+
78
+ enc_key, mac_key = fj_derive_keys(passphrase, salt)
79
+ expected = OpenSSL::HMAC.digest("SHA256", mac_key, iv + ct)
80
+ unless mac.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(mac, expected)
81
+ raise EncryptionError, "decryption failed — wrong passphrase or corrupted ciphertext"
82
+ end
83
+
84
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
85
+ cipher.decrypt
86
+ cipher.key = enc_key
87
+ cipher.iv = iv
88
+ begin
89
+ (cipher.update(ct) + cipher.final).force_encoding("UTF-8")
90
+ rescue OpenSSL::Cipher::CipherError
91
+ raise EncryptionError, "decryption failed — wrong passphrase or corrupted ciphertext"
92
+ end
93
+ end
94
+
95
+ # PBKDF2-HMAC-SHA256 -> 64 bytes split into (AES-256-CBC key, HMAC-SHA256 key).
96
+ def self.fj_derive_keys(passphrase, salt)
97
+ dk = OpenSSL::KDF.pbkdf2_hmac(
98
+ passphrase.to_s,
99
+ salt: salt, iterations: FJ_ITERATIONS, length: 64, hash: "sha256"
100
+ )
101
+ [dk.byteslice(0, 32), dk.byteslice(32, 32)]
102
+ end
103
+ private_class_method :fj_derive_keys
104
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Freshjots
4
- VERSION = "1.0.2"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/freshjots.rb CHANGED
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "uri"
6
6
 
7
7
  require_relative "freshjots/version"
8
+ require_relative "freshjots/crypto"
8
9
 
9
10
  # Tiny client for the Fresh Jots API (https://freshjots.com/docs).
10
11
  #
@@ -33,6 +34,11 @@ module Freshjots
33
34
 
34
35
  class Client
35
36
  DEFAULT_BASE_URL = "https://freshjots.com/api/v1"
37
+ BULK_MAX = 50
38
+ # Friendly field names accepted by #update / #set, mapped to the API's
39
+ # note keys in #note_fields. append_only / format are intentionally
40
+ # absent — the API does not update them.
41
+ UPDATABLE_FIELDS = %i[title body folder root deadline alert_email webhook_url webhook_secret].freeze
36
42
 
37
43
  def initialize(token: ENV["FRESHJOTS_TOKEN"], base_url: DEFAULT_BASE_URL)
38
44
  raise ArgumentError, "FRESHJOTS_TOKEN missing — pass token: or set the env var" if token.nil? || token.empty?
@@ -71,24 +77,61 @@ module Freshjots
71
77
  # (the by-filename endpoint creates it with that exact name on first
72
78
  # call). Returns the created note hash (top level); read [:filename]
73
79
  # for the server-derived stream name.
74
- def create(title:, body: "")
80
+ # Pass client_encrypted: true to mark the note as a client-encrypted note
81
+ # — body is opaque ciphertext you produced with Freshjots.encrypt; the
82
+ # server stores it verbatim and never reads it. Personal accounts only.
83
+ def create(title:, body: "", client_encrypted: false)
75
84
  if title.nil? || title.to_s.empty?
76
85
  raise ArgumentError,
77
86
  "create requires a title — the API derives the filename from it. " \
78
87
  "For a note addressable by an exact filename, use append."
79
88
  end
80
- payload = { note: { title: title, plain_body: body, format: "plain" } }
81
- request(:post, "/notes", payload)
89
+ note = { title: title, plain_body: body, format: "plain" }
90
+ note[:client_encrypted] = true if client_encrypted
91
+ request(:post, "/notes", { note: note })
82
92
  end
83
93
 
84
- def append(filename, text)
85
- request(:post, "/notes/by-filename/#{escape(filename)}/append", { text: text })
94
+ # On first-touch creation, pass client_encrypted: true to open the stream
95
+ # as a client-encrypted note (send one ciphertext line per append).
96
+ # Ignored once the note exists.
97
+ def append(filename, text, client_encrypted: false)
98
+ body = { text: text }
99
+ body[:client_encrypted] = true if client_encrypted
100
+ request(:post, "/notes/by-filename/#{escape(filename)}/append", body)
86
101
  true
87
102
  end
88
103
 
104
+ # Update a note by id. Pass any of: title:, body:, folder:, root: true,
105
+ # deadline:, alert_email:, webhook_url:, webhook_secret: — only the keys
106
+ # you pass are changed, so an unmentioned field is never clobbered. A
107
+ # content change (title/body) rewrites the body as a unit, so a title
108
+ # change needs body: too (the API requires plain_body). append_only and
109
+ # format are not updatable. Returns the updated note hash (top level).
110
+ def update(id, **fields)
111
+ request(:patch, "/notes/#{escape(id)}", { note: note_fields(fields) })
112
+ end
113
+
114
+ # Update a note addressed by its exact filename / stream name. Same
115
+ # fields as #update.
116
+ def set(filename, **fields)
117
+ request(:patch, "/notes/by-filename/#{escape(filename)}", { note: note_fields(fields) })
118
+ end
119
+
120
+ # Create up to 50 notes in one atomic batch (all land or none do).
121
+ # `notes` is an array of note hashes ({ title:, plain_body:,
122
+ # format: "plain" }). Returns the response ({ created: [...] }).
123
+ def bulk(notes)
124
+ items = Array(notes)
125
+ raise ArgumentError, "bulk requires at least one note" if items.empty?
126
+ raise ArgumentError, "bulk accepts at most #{BULK_MAX} notes (got #{items.size})" if items.size > BULK_MAX
127
+
128
+ request(:post, "/notes/bulk", { notes: items })
129
+ end
130
+
89
131
  # Delete a note. Accepts a numeric id or a filename (resolved to its
90
- # id via the by-filename lookup). Locked (append-only) notes are
91
- # refused by the API with note_locked. Returns true on success.
132
+ # id via the by-filename lookup). Works on any note, including locked
133
+ # (append-only) ones the lock freezes content, not deletability.
134
+ # Returns true on success.
92
135
  def delete(id_or_filename)
93
136
  request(:delete, "/notes/#{resolve_note_id(id_or_filename)}")
94
137
  true
@@ -107,6 +150,28 @@ module Freshjots
107
150
  request(:get, "/folders")[:folders]
108
151
  end
109
152
 
153
+ # Fetch one folder by id (GET /folders/:id) — top-level serializer.
154
+ def folder(id)
155
+ request(:get, "/folders/#{escape(id)}")
156
+ end
157
+
158
+ # Create a folder. Returns the created folder hash (top level).
159
+ def create_folder(name)
160
+ request(:post, "/folders", { folder: { name: name } })
161
+ end
162
+
163
+ # Rename a folder. Returns the updated folder hash.
164
+ def rename_folder(id, name)
165
+ request(:patch, "/folders/#{escape(id)}", { folder: { name: name } })
166
+ end
167
+
168
+ # Delete a folder by id. Its notes survive — they just become
169
+ # un-foldered. Returns true.
170
+ def delete_folder(id)
171
+ request(:delete, "/folders/#{escape(id)}")
172
+ true
173
+ end
174
+
110
175
  private
111
176
 
112
177
  # A note reference is either a numeric id (used as-is) or a
@@ -130,6 +195,42 @@ module Freshjots
130
195
  matches.first[:id]
131
196
  end
132
197
 
198
+ # Map the friendly #update / #set keyword fields to the API's note keys,
199
+ # sending only what the caller passed. A title change rewrites the body
200
+ # as a unit (the API requires plain_body), so a title-only change is
201
+ # refused here — mirrors the CLI. Unknown fields and an empty change are
202
+ # errors. `folder:` accepts an id or a name (resolved via /folders);
203
+ # `root: true` un-folders the note.
204
+ def note_fields(fields)
205
+ unknown = fields.keys - UPDATABLE_FIELDS
206
+ unless unknown.empty?
207
+ raise ArgumentError,
208
+ "unknown update field(s): #{unknown.join(', ')}. " \
209
+ "allowed: #{UPDATABLE_FIELDS.join(', ')} (append_only/format are not updatable)"
210
+ end
211
+ if fields.key?(:title) && !fields.key?(:body)
212
+ raise ArgumentError,
213
+ "changing the title also rewrites the body — pass body: too. For metadata " \
214
+ "only, use folder/root/deadline/alert_email/webhook_* without title."
215
+ end
216
+
217
+ note = {}
218
+ note[:title] = fields[:title] if fields.key?(:title)
219
+ note[:plain_body] = fields[:body] if fields.key?(:body)
220
+ if fields[:root]
221
+ note[:folder_id] = nil
222
+ elsif fields.key?(:folder)
223
+ note[:folder_id] = resolve_folder_id(fields[:folder])
224
+ end
225
+ note[:append_deadline_hours] = fields[:deadline] if fields.key?(:deadline)
226
+ note[:alert_email] = fields[:alert_email] if fields.key?(:alert_email)
227
+ note[:webhook_url] = fields[:webhook_url] if fields.key?(:webhook_url)
228
+ note[:webhook_secret] = fields[:webhook_secret] if fields.key?(:webhook_secret)
229
+ raise ArgumentError, "no fields to update" if note.empty?
230
+
231
+ note
232
+ end
233
+
133
234
  def request(method, path, body = nil)
134
235
  uri = URI("#{@base_url}#{path}")
135
236
  req = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freshjots
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Goran Arsov
@@ -20,6 +20,7 @@ files:
20
20
  - LICENSE
21
21
  - README.md
22
22
  - lib/freshjots.rb
23
+ - lib/freshjots/crypto.rb
23
24
  - lib/freshjots/version.rb
24
25
  homepage: https://github.com/Goran-Arsov/freshjots-ruby
25
26
  licenses: