familia 2.0.0.pre8 → 2.0.0.pre12

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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -0
  3. data/.github/workflows/docs.yml +1 -1
  4. data/.gitignore +9 -9
  5. data/.rubocop.yml +19 -0
  6. data/.yardopts +22 -1
  7. data/CHANGELOG.md +247 -0
  8. data/CLAUDE.md +12 -59
  9. data/Gemfile.lock +1 -1
  10. data/README.md +62 -2
  11. data/changelog.d/README.md +77 -0
  12. data/docs/archive/.gitignore +2 -0
  13. data/docs/archive/FAMILIA_RELATIONSHIPS.md +210 -0
  14. data/docs/archive/FAMILIA_TECHNICAL.md +823 -0
  15. data/docs/archive/FAMILIA_UPDATE.md +226 -0
  16. data/docs/archive/README.md +63 -0
  17. data/docs/guides/.gitignore +2 -0
  18. data/docs/{wiki → guides}/Home.md +1 -1
  19. data/docs/{wiki → guides}/Implementation-Guide.md +1 -1
  20. data/docs/{wiki → guides}/Relationships-Guide.md +103 -50
  21. data/docs/guides/relationships-methods.md +266 -0
  22. data/docs/migrating/.gitignore +2 -0
  23. data/docs/migrating/v2.0.0-pre.md +84 -0
  24. data/docs/migrating/v2.0.0-pre11.md +255 -0
  25. data/docs/migrating/v2.0.0-pre12.md +306 -0
  26. data/docs/migrating/v2.0.0-pre5.md +110 -0
  27. data/docs/migrating/v2.0.0-pre6.md +154 -0
  28. data/docs/migrating/v2.0.0-pre7.md +222 -0
  29. data/docs/overview.md +6 -7
  30. data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
  31. data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
  32. data/examples/relationships.rb +205 -0
  33. data/examples/safe_dump.rb +281 -0
  34. data/familia.gemspec +4 -4
  35. data/lib/familia/base.rb +52 -0
  36. data/lib/familia/connection.rb +4 -21
  37. data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
  38. data/lib/familia/errors.rb +2 -0
  39. data/lib/familia/features/autoloader.rb +57 -0
  40. data/lib/familia/features/external_identifier.rb +310 -0
  41. data/lib/familia/features/object_identifier.rb +307 -0
  42. data/lib/familia/features/relationships/indexing.rb +160 -175
  43. data/lib/familia/features/relationships/membership.rb +16 -21
  44. data/lib/familia/features/relationships/tracking.rb +61 -21
  45. data/lib/familia/features/relationships.rb +15 -8
  46. data/lib/familia/features/safe_dump.rb +66 -72
  47. data/lib/familia/features.rb +93 -5
  48. data/lib/familia/horreum/subclass/definition.rb +49 -3
  49. data/lib/familia/horreum.rb +15 -24
  50. data/lib/familia/secure_identifier.rb +51 -75
  51. data/lib/familia/verifiable_identifier.rb +162 -0
  52. data/lib/familia/version.rb +1 -1
  53. data/lib/familia.rb +1 -0
  54. data/setup.cfg +5 -0
  55. data/try/core/secure_identifier_try.rb +47 -18
  56. data/try/core/verifiable_identifier_try.rb +171 -0
  57. data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
  58. data/try/features/feature_improvements_try.rb +126 -0
  59. data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
  60. data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
  61. data/try/features/real_feature_integration_try.rb +7 -6
  62. data/try/features/relationships/relationships_api_changes_try.rb +339 -0
  63. data/try/features/relationships/relationships_try.rb +6 -5
  64. data/try/features/safe_dump/safe_dump_try.rb +8 -9
  65. data/try/helpers/test_helpers.rb +17 -17
  66. metadata +62 -41
  67. data/examples/relationships_basic.rb +0 -273
  68. data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
  69. data/lib/familia/features/external_identifiers.rb +0 -111
  70. data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
  71. data/lib/familia/features/object_identifiers.rb +0 -194
  72. /data/docs/{wiki → guides}/API-Reference.md +0 -0
  73. /data/docs/{wiki → guides}/Connection-Pooling-Guide.md +0 -0
  74. /data/docs/{wiki → guides}/Encrypted-Fields-Overview.md +0 -0
  75. /data/docs/{wiki → guides}/Expiration-Feature-Guide.md +0 -0
  76. /data/docs/{wiki → guides}/Feature-System-Guide.md +0 -0
  77. /data/docs/{wiki → guides}/Features-System-Developer-Guide.md +0 -0
  78. /data/docs/{wiki → guides}/Field-System-Guide.md +0 -0
  79. /data/docs/{wiki → guides}/Quantization-Feature-Guide.md +0 -0
  80. /data/docs/{wiki → guides}/Security-Model.md +0 -0
  81. /data/docs/{wiki → guides}/Transient-Fields-Guide.md +0 -0
@@ -2,13 +2,14 @@
2
2
 
3
3
  require 'securerandom'
4
4
 
5
+ # Provides a suite of tools for generating and manipulating cryptographically
6
+ # secure identifiers in various formats and lengths.
5
7
  module Familia
6
8
  module SecureIdentifier
7
-
8
9
  # Generates a 256-bit cryptographically secure hexadecimal identifier.
9
10
  #
10
11
  # @return [String] A 64-character hex string representing 256 bits of entropy.
11
- # @security Provides ~10^77 possible values, far exceeding UUID4's 128 bits.
12
+ # @security Provides ~10^77 possible values, far exceeding UUIDv4's 128 bits.
12
13
  def generate_hex_id
13
14
  SecureRandom.hex(32)
14
15
  end
@@ -26,13 +27,6 @@ module Familia
26
27
  #
27
28
  # @param base [Integer] The base for encoding the output string (2-36, default: 36).
28
29
  # @return [String] A secure identifier.
29
- #
30
- # @example Generate a 256-bit ID in base-36 (default)
31
- # generate_id # => "25nkfebno45yy36z47ffxef2a7vpg4qk06ylgxzwgpnz4q3os4"
32
- #
33
- # @example Generate a 256-bit ID in base-16 (hexadecimal)
34
- # generate_id(16) # => "568bdb582bc5042bf435d3f126cf71593981067463709c880c91df1ad9777a34"
35
- #
36
30
  def generate_id(base = 36)
37
31
  target_length = SecureIdentifier.min_length_for_bits(256, base)
38
32
  generate_hex_id.to_i(16).to_s(base).rjust(target_length, '0')
@@ -43,87 +37,69 @@ module Familia
43
37
  #
44
38
  # @param base [Integer] The base for encoding the output string (2-36, default: 36).
45
39
  # @return [String] A secure short identifier.
46
- #
47
- # @example Generate a 64-bit short ID in base-36 (default)
48
- # generate_trace_id # => "lh7uap704unf"
49
- #
50
- # @example Generate a 64-bit short ID in base-16 (hexadecimal)
51
- # generate_trace_id(16) # => "94cf9f8cfb0eb692"
52
- #
53
40
  def generate_trace_id(base = 36)
54
41
  target_length = SecureIdentifier.min_length_for_bits(64, base)
55
42
  generate_hex_trace_id.to_i(16).to_s(base).rjust(target_length, '0')
56
43
  end
57
44
 
58
- # Truncates a 256-bit hexadecimal ID to 64 bits and encodes it in a given base.
59
- # These short, deterministic IDs are useful for secure logging. By inputting the
60
- # full hexadecimal string, you can generate a consistent short ID that allows
61
- # tracking an entity through logs without exposing the entity's full identifier..
45
+ # Creates a deterministic 64-bit trace identifier from a longer hex ID.
62
46
  #
63
- # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
64
- # @param base [Integer] The base for encoding the output string (2-36, default: 36).
65
- # @return [String] A 64-bit identifier, encoded in the specified base.
47
+ # This is a convenience method for `truncate_hex(hex_id, bits: 64)`.
48
+ # Useful for creating short, consistent IDs for logging and tracing.
49
+ #
50
+ # @param (see #truncate_hex)
51
+ # @return (see #truncate_hex)
66
52
  def shorten_to_trace_id(hex_id, base: 36)
67
- target_length = SecureIdentifier.min_length_for_bits(64, base)
68
- truncated = hex_id.to_i(16) >> (256 - 64) # Always 64 bits
69
- truncated.to_s(base).rjust(target_length, '0')
53
+ truncate_hex(hex_id, bits: 64, base: base)
70
54
  end
71
55
 
72
- # Truncates a 256-bit hexadecimal ID to 128 bits and encodes it in a given base.
73
- # This function takes the most significant bits from the hex string to maintain
74
- # randomness while creating a shorter, deterministic identifier that's safe for
75
- # outdoor use.
76
- #
77
- # @param hex_id [String] A 64-character hexadecimal string (representing 256 bits).
78
- # @param base [Integer] The base for encoding the output string (2-36, default: 36).
79
- # @return [String] A 128-bit identifier, encoded in the specified base.
80
- #
81
- # @example Create a shorter external ID from a full 256-bit internal ID
82
- # hex_id = generate_hex_id
83
- # external_id = shorten_to_external_id(hex_id)
84
- #
85
- # @note This is useful for creating shorter, public-facing IDs from secure internal ones.
86
- # @security Truncation preserves the cryptographic properties of the most significant bits.
87
- def shorten_to_external_id(hex_id, base: 36)
88
- target_length = SecureIdentifier.min_length_for_bits(128, base)
89
- truncated = hex_id.to_i(16) >> (256 - 128) # Always 128 bits
90
- truncated.to_s(base).rjust(target_length, '0')
91
- end
56
+ # Deterministically truncates a hexadecimal ID to a specified bit length.
57
+ #
58
+ # This function preserves the most significant bits of the input `hex_id` to
59
+ # create a shorter, yet still random-looking, identifier.
60
+ #
61
+ # @param hex_id [String] The input hexadecimal string.
62
+ # @param bits [Integer] The desired output bit length (e.g., 128, 64). Defaults to 128.
63
+ # @param base [Integer] The numeric base for the output string (2-36). Defaults to 36.
64
+ # @return [String] A new, shorter identifier in the specified base.
65
+ # @raise [ArgumentError] if `hex_id` is not a valid hex string, or if `input_bits`
66
+ # is less than the desired output `bits`.
67
+ def truncate_hex(hex_id, bits: 128, base: 36)
68
+ target_length = SecureIdentifier.min_length_for_bits(bits, base)
69
+ input_bits = hex_id.length * 4
92
70
 
93
- # Calculate minimum string length to represent N bits in given base
94
- #
95
- # When generating random IDs, we need to know how many characters are required
96
- # to represent a certain amount of entropy. This ensures consistent ID lengths.
97
- #
98
- # Formula: ceil(bits * log(2) / log(base))
99
- #
100
- # @example Common usage with SecureRandom
101
- # SecureRandom.hex(32) # 32 bytes = 256 bits = 64 hex chars
102
- # SecureRandom.hex(16) # 16 bytes = 128 bits = 32 hex chars
103
- #
104
- # @example Using the method
105
- # min_length_for_bits(256, 16) # => 64 (hex)
106
- # min_length_for_bits(256, 36) # => 50 (base36)
107
- # min_length_for_bits(128, 10) # => 39 (decimal)
71
+ unless hex_id.match?(/\A[0-9a-fA-F]+\z/)
72
+ raise ArgumentError, "Invalid hexadecimal string: #{hex_id}"
73
+ end
108
74
 
109
- # Fast lookup for hex (base 16) - our most common case
110
- # Avoids calculation overhead for 99% of ID generation
111
- HEX_LENGTHS = {
112
- 256 => 64, # SHA-256 equivalent entropy
113
- 128 => 32, # UUID equivalent entropy
114
- 64 => 16, # Compact ID
115
- }.freeze
75
+ if input_bits < bits
76
+ raise ArgumentError, "Input bits (#{input_bits}) cannot be less than desired output bits (#{bits})."
77
+ end
116
78
 
117
- # Get minimum character length needed to encode `bits` of entropy in `base`
79
+ # Truncate by right-shifting to keep the most significant bits
80
+ truncated_int = hex_id.to_i(16) >> (input_bits - bits)
81
+ truncated_int.to_s(base).rjust(target_length, '0')
82
+ end
83
+
84
+ # Calculates the minimum string length required to represent a given number of
85
+ # bits in a specific numeric base.
86
+ #
87
+ # @private
118
88
  #
119
- # @param bits [Integer] Number of bits of entropy needed
120
- # @param base [Integer] Numeric base (2-36)
121
- # @return [Integer] Minimum string length required
89
+ # @param bits [Integer] The number of bits of entropy.
90
+ # @param base [Integer] The numeric base (2-36).
91
+ # @return [Integer] The minimum string length required.
122
92
  def self.min_length_for_bits(bits, base)
123
- return HEX_LENGTHS[bits] if base == 16 && HEX_LENGTHS.key?(bits)
93
+ # Fast lookup for hex (base 16) - our most common case
94
+ hex_lengths = {
95
+ 256 => 64, # SHA-256 equivalent entropy
96
+ 128 => 32, # UUID equivalent entropy
97
+ 64 => 16, # Compact ID
98
+ }.freeze
99
+ return hex_lengths[bits] if base == 16 && hex_lengths.key?(bits)
124
100
 
125
- @length_cache ||= {}
126
- @length_cache[[bits, base]] ||= (bits * Math.log(2) / Math.log(base)).ceil
101
+ @min_length_for_bits_cache ||= {}
102
+ @min_length_for_bits_cache[[bits, base]] ||= (bits * Math.log(2) / Math.log(base)).ceil
127
103
  end
128
104
  end
129
105
  end
@@ -0,0 +1,162 @@
1
+ # lib/familia/verifiable_identifier.rb
2
+
3
+ require 'openssl'
4
+ require_relative 'secure_identifier'
5
+
6
+ module Familia
7
+ # Creates and verifies identifiers that contain an embedded HMAC signature,
8
+ # allowing for stateless verification of an identifier's authenticity.
9
+ module VerifiableIdentifier
10
+ # By extending SecureIdentifier, we gain access to its instance methods
11
+ # (like generate_hex_id) as class methods on this module.
12
+ extend Familia::SecureIdentifier
13
+
14
+ # The secret key for HMAC generation, loaded from an environment variable.
15
+ #
16
+ # This key is the root of trust for verifying identifier authenticity. It must be
17
+ # a long, random, and cryptographically strong string.
18
+ #
19
+ # @!attribute [r] SECRET_KEY
20
+ # @return [String] The secret key.
21
+ #
22
+ # @note Security Considerations:
23
+ # - **Secrecy:** This key MUST be kept secret and secure, just like a database
24
+ # password or API key. Do not commit it to version control.
25
+ # - **Consistency:** All running instances of your application must use the
26
+ # exact same key, otherwise verification will fail across different servers.
27
+ # - **Rotation:** If this key is ever compromised, it must be rotated. Be
28
+ # aware that rotating the key will invalidate all previously generated
29
+ # verifiable identifiers.
30
+ #
31
+ # @example Generating and Setting the Key
32
+ # 1. Generate a new secure key in your terminal:
33
+ # $ openssl rand -hex 32
34
+ # > cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d
35
+ #
36
+ # 2. Set it as an environment variable in your production environment:
37
+ # export VERIFIABLE_ID_HMAC_SECRET="cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
38
+ #
39
+ SECRET_KEY = ENV.fetch('VERIFIABLE_ID_HMAC_SECRET', 'cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d')
40
+
41
+ # The length of the random part of the ID in hex characters (256 bits).
42
+ RANDOM_HEX_LENGTH = 64
43
+ # The length of the HMAC tag in hex characters (64 bits).
44
+ # 64 bits is strong enough to prevent forgery (1 in 18 quintillion chance).
45
+ TAG_HEX_LENGTH = 16
46
+
47
+ # Generates a verifiable, base-36 encoded identifier.
48
+ #
49
+ # The final identifier contains a 256-bit random component and a 64-bit
50
+ # authentication tag.
51
+ #
52
+ # @param base [Integer] The base for encoding the output string.
53
+ # @return [String] A verifiable, signed identifier.
54
+ def self.generate_verifiable_id(base_or_scope = nil, scope: nil, base: 36)
55
+ # Handle backward compatibility with positional base argument
56
+ if base_or_scope.is_a?(Integer)
57
+ base = base_or_scope
58
+ # scope remains as passed in keyword argument
59
+ elsif base_or_scope.is_a?(String) || base_or_scope.nil?
60
+ scope = base_or_scope if scope.nil?
61
+ # base remains as passed in keyword argument or default
62
+ end
63
+
64
+ # Re-use generate_hex_id from the SecureIdentifier module.
65
+ random_hex = generate_hex_id
66
+ tag_hex = generate_tag(random_hex, scope: scope)
67
+
68
+ combined_hex = random_hex + tag_hex
69
+
70
+ # Re-use the min_length_for_bits helper from the SecureIdentifier module.
71
+ total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
72
+ target_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
73
+
74
+ combined_hex.to_i(16).to_s(base).rjust(target_length, '0')
75
+ end
76
+
77
+ # Verifies the authenticity of a given identifier using a timing-safe comparison.
78
+ #
79
+ # @param verifiable_id [String] The identifier string to check.
80
+ # @param base [Integer] The base of the input string.
81
+ # @return [Boolean] True if the identifier is authentic, false otherwise.
82
+ def self.verified_identifier?(verifiable_id, base_or_scope = nil, scope: nil, base: 36)
83
+ # Handle backward compatibility with positional base argument
84
+ if base_or_scope.is_a?(Integer)
85
+ base = base_or_scope
86
+ # scope remains as passed in keyword argument
87
+ elsif base_or_scope.is_a?(String) || base_or_scope.nil?
88
+ scope = base_or_scope if scope.nil?
89
+ # base remains as passed in keyword argument or default
90
+ end
91
+
92
+ return false unless plausible_identifier?(verifiable_id, base)
93
+
94
+ expected_hex_length = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH)
95
+ combined_hex = verifiable_id.to_i(base).to_s(16).rjust(expected_hex_length, '0')
96
+
97
+ random_part = combined_hex[0...RANDOM_HEX_LENGTH]
98
+ tag_part = combined_hex[RANDOM_HEX_LENGTH..]
99
+
100
+ expected_tag = generate_tag(random_part, scope: scope)
101
+ OpenSSL.secure_compare(expected_tag, tag_part)
102
+ end
103
+
104
+ # Checks if an identifier is plausible (correct format and length) without
105
+ # performing cryptographic verification.
106
+ #
107
+ # This can be used as a fast pre-flight check to reject obviously
108
+ # malformed identifiers.
109
+ #
110
+ # @param identifier_str [String] The identifier string to check.
111
+ # @param base [Integer] The base of the input string.
112
+ # @return [Boolean] True if the identifier has a valid format, false otherwise.
113
+ def self.plausible_identifier?(identifier_str, base = 36)
114
+ return false unless identifier_str.is_a?(::String)
115
+
116
+ # 1. Check length
117
+ total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
118
+ expected_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
119
+ return false unless identifier_str.length == expected_length
120
+
121
+ # 2. Check character set
122
+ # The most efficient way to check for invalid characters is to attempt
123
+ # conversion and rescue the error.
124
+ Integer(identifier_str, base)
125
+ true
126
+ rescue ArgumentError
127
+ false
128
+ end
129
+
130
+ class << self
131
+ private
132
+
133
+ # Generates the HMAC tag for a given message.
134
+ # @private
135
+ def generate_tag(message, scope: nil)
136
+ # Include scope in HMAC calculation for domain separation if provided.
137
+ # The scope parameter enables creating cryptographically isolated identifier
138
+ # namespaces (e.g., per-domain, per-tenant, per-application) while maintaining
139
+ # all security properties of the base system.
140
+ #
141
+ # Security considerations for scope values:
142
+ # - Any string content is cryptographically safe (HMAC handles arbitrary input)
143
+ # - No length restrictions (short scopes like "a" or long scopes work equally well)
144
+ # - UTF-8 encoding is handled consistently
145
+ # - Empty string "" vs nil produce different identifiers (intentional for security)
146
+ # - Different scope values guarantee different identifier spaces
147
+ #
148
+ # Examples of scope usage:
149
+ # - Customer isolation: scope: "tenant:#{tenant_id}"
150
+ # - Environment separation: scope: "production" vs scope: "staging"
151
+ # - Domain scoping: scope: "example.com"
152
+ # - Application scoping: scope: "#{app_name}:#{version}"
153
+ hmac_input = scope ? "#{message}:scope:#{scope}" : message
154
+
155
+ digest = OpenSSL::Digest.new('sha256')
156
+ hmac = OpenSSL::HMAC.hexdigest(digest, SECRET_KEY, hmac_input)
157
+ # Truncate to the desired length for the tag.
158
+ hmac[0...TAG_HEX_LENGTH]
159
+ end
160
+ end
161
+ end
162
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Familia
4
4
  # Version information for the Familia
5
- VERSION = '2.0.0.pre8'.freeze unless defined?(Familia::VERSION)
5
+ VERSION = '2.0.0.pre12'.freeze unless defined?(Familia::VERSION)
6
6
  end
data/lib/familia.rb CHANGED
@@ -81,6 +81,7 @@ end
81
81
 
82
82
  require_relative 'familia/base'
83
83
  require_relative 'familia/features'
84
+ require_relative 'familia/features/autoloader'
84
85
  require_relative 'familia/data_type'
85
86
  require_relative 'familia/horreum'
86
87
  require_relative 'familia/encryption'
data/setup.cfg ADDED
@@ -0,0 +1,5 @@
1
+ [scriv]
2
+ categories = Added, Changed, Deprecated, Removed, Fixed, Security, Documentation, AI Assistance
3
+ version = command: ruby -r ./lib/familia/version.rb -e "puts Familia::VERSION"
4
+ main_branches = main, develop
5
+ md_header_level = 2
@@ -56,22 +56,6 @@ hex_trace_id = Familia.generate_hex_trace_id
56
56
  [hex_trace_id.class, hex_trace_id.length == 16, hex_trace_id.match?(/^[a-f0-9]+$/)]
57
57
  #=> [String, true, true]
58
58
 
59
- ## Familia.shorten_to_external_id
60
- Familia.respond_to?(:shorten_to_external_id)
61
- #=> true
62
-
63
- ## Can shorten hex ID to external ID (128 bits)
64
- hex_id = Familia.generate_hex_id
65
- external_id = Familia.shorten_to_external_id(hex_id)
66
- [external_id.class, external_id.length < hex_id.length]
67
- #=> [String, true]
68
-
69
- ## Can shorten hex ID to external ID with custom base (hex)
70
- hex_id = Familia.generate_hex_id
71
- hex_external_id = Familia.shorten_to_external_id(hex_id, base: 16)
72
- [hex_external_id.class, hex_external_id.length == 32]
73
- #=> [String, true]
74
-
75
59
  ## Familia.shorten_to_trace_id
76
60
  Familia.respond_to?(:shorten_to_trace_id)
77
61
  #=> true
@@ -88,10 +72,55 @@ hex_trace_id = Familia.shorten_to_trace_id(hex_id, base: 16)
88
72
  [hex_trace_id.class, hex_trace_id.length == 16]
89
73
  #=> [String, true]
90
74
 
75
+ ## Familia.truncate_hex
76
+ Familia.respond_to?(:truncate_hex)
77
+ #=> true
78
+
79
+ ## Can truncate hex ID to 128 bits by default
80
+ hex_id = Familia.generate_hex_id
81
+ truncated_id = Familia.truncate_hex(hex_id)
82
+ [truncated_id.class, truncated_id.length < hex_id.length]
83
+ #=> [String, true]
84
+
85
+ ## Can truncate hex ID to a custom bit length (64 bits)
86
+ hex_id = Familia.generate_hex_id
87
+ truncated_64 = Familia.truncate_hex(hex_id, bits: 64)
88
+ [truncated_64.class, truncated_64.length < hex_id.length]
89
+ #=> [String, true]
90
+
91
+ ## Can truncate with a custom base (hex)
92
+ hex_id = Familia.generate_hex_id
93
+ hex_truncated = Familia.truncate_hex(hex_id, bits: 128, base: 16)
94
+ [hex_truncated.class, hex_truncated.length == 32]
95
+ #=> [String, true]
96
+
97
+ ## Truncated IDs are deterministic
98
+ hex_id = Familia.generate_hex_id
99
+ id1 = Familia.truncate_hex(hex_id)
100
+ id2 = Familia.truncate_hex(hex_id)
101
+ id1 == id2
102
+ #=> true
103
+
104
+ ## Raises error for invalid hex
105
+ begin
106
+ Familia.truncate_hex("not-a-hex-string")
107
+ rescue ArgumentError => e
108
+ e.message
109
+ end
110
+ #=> "Invalid hexadecimal string: not-a-hex-string"
111
+
112
+ ## Raises error if input bits are less than output bits
113
+ begin
114
+ Familia.truncate_hex("abc", bits: 64)
115
+ rescue ArgumentError => e
116
+ e.message
117
+ end
118
+ #=> "Input bits (12) cannot be less than desired output bits (64)."
119
+
91
120
  ## Shortened IDs are deterministic
92
121
  hex_id = Familia.generate_hex_id
93
- id1 = Familia.shorten_to_external_id(hex_id)
94
- id2 = Familia.shorten_to_external_id(hex_id)
122
+ id1 = Familia.shorten_to_trace_id(hex_id)
123
+ id2 = Familia.shorten_to_trace_id(hex_id)
95
124
  id1 == id2
96
125
  #=> true
97
126
 
@@ -0,0 +1,171 @@
1
+ # try/core/verifiable_identifier_try.rb
2
+
3
+ require_relative '../helpers/test_helpers'
4
+ require 'familia/verifiable_identifier'
5
+
6
+ ## Module is available
7
+ defined?(Familia::VerifiableIdentifier)
8
+ #=> "constant"
9
+
10
+ ## Uses the development secret key by default when ENV is not set
11
+ Familia::VerifiableIdentifier::SECRET_KEY
12
+ #=> "cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
13
+
14
+ # --- Verifiable ID Generation and Verification ---
15
+
16
+ ## Generates a non-empty string ID
17
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
18
+ id.is_a?(String) && !id.empty?
19
+ #=> true
20
+
21
+ ## Generated ID is URL-safe (base36)
22
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
23
+ id.match?(/[a-z0-9]+/ix)
24
+ #=> true
25
+
26
+ ## Generates unique identifiers on subsequent calls
27
+ Familia::VerifiableIdentifier.generate_verifiable_id
28
+ #=/> Familia::VerifiableIdentifier.generate_verifiable_id
29
+
30
+ ## A genuinely generated ID successfully verifies
31
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
32
+ Familia::VerifiableIdentifier.verified_identifier?(id)
33
+ #=> true
34
+
35
+ ## Fails verification for a completely random garbage string
36
+ Familia::VerifiableIdentifier.verified_identifier?('this-is-not-a-valid-id-at-all')
37
+ #=> false
38
+
39
+ ## Fails verification for a string with invalid characters for the base
40
+ # A plus sign is not a valid base-36 character.
41
+ Familia::VerifiableIdentifier.verified_identifier?('this+is+invalid', 36)
42
+ #=> false
43
+
44
+ ## Fails verification if the random part of the ID is tampered with
45
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
46
+ tampered_id = id.dup
47
+ tampered_id[0] = (tampered_id[0] == 'a' ? 'b' : 'a') # Flip the first character
48
+ Familia::VerifiableIdentifier.verified_identifier?(tampered_id)
49
+ #=> false
50
+
51
+ ## Fails verification if the tag part of the ID is tampered with
52
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
53
+ tampered_id = id.dup
54
+ idx = tampered_id.length - 1
55
+ tampered_id[idx] = (tampered_id[idx] == 'a' ? 'b' : 'a') # Flip the last character
56
+ Familia::VerifiableIdentifier.verified_identifier?(tampered_id)
57
+ #=> false
58
+
59
+ ## Works correctly with a different base (hexadecimal)
60
+ id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(16)
61
+ Familia::VerifiableIdentifier.verified_identifier?(id_hex, 16)
62
+ #=> true
63
+
64
+ ## Base 16 ID has the correct hex length (64 random + 16 tag = 80 chars)
65
+ id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(16)
66
+ id_hex.length
67
+ #=> 80
68
+
69
+ # --- Plausibility Checks ---
70
+
71
+ ## A genuinely generated ID is plausible
72
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
73
+ Familia::VerifiableIdentifier.plausible_identifier?(id)
74
+ #=> true
75
+
76
+ ## A well-formed but fake ID is still plausible
77
+ # A string of the correct length (62 for base 36) and charset is plausible
78
+ total_bits = (Familia::VerifiableIdentifier::RANDOM_HEX_LENGTH + Familia::VerifiableIdentifier::TAG_HEX_LENGTH) * 4
79
+ fake_id = 'a' * Familia::SecureIdentifier.min_length_for_bits(total_bits, 36)
80
+ Familia::VerifiableIdentifier.plausible_identifier?(fake_id)
81
+ #=> true
82
+
83
+ ## Fails plausibility check if too short
84
+ short_id = 'a' * 60
85
+ Familia::VerifiableIdentifier.plausible_identifier?(short_id)
86
+ #=> false
87
+
88
+ ## Fails plausibility check if too long
89
+ long_id = 'a' * 66
90
+ Familia::VerifiableIdentifier.plausible_identifier?(long_id)
91
+ #=> false
92
+
93
+ ## Fails plausibility check for invalid characters
94
+ invalid_char_id = 'a' * 61 + '+'
95
+ Familia::VerifiableIdentifier.plausible_identifier?(invalid_char_id)
96
+ #=> false
97
+
98
+ ## Fails plausibility check for nil input
99
+ Familia::VerifiableIdentifier.plausible_identifier?(nil)
100
+ #=> false
101
+
102
+ # --- Scoped Identifier Generation and Verification ---
103
+
104
+ ## Scoped identifier generation produces different results than unscoped
105
+ scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
106
+ unscoped_id = Familia::VerifiableIdentifier.generate_verifiable_id
107
+ scoped_id != unscoped_id
108
+ #=> true
109
+
110
+ ## Scoped identifiers verify successfully with correct scope
111
+ scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
112
+ Familia::VerifiableIdentifier.verified_identifier?(scoped_id, scope: "example.com")
113
+ #=> true
114
+
115
+ ## Scoped identifiers fail verification with wrong scope
116
+ scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
117
+ Familia::VerifiableIdentifier.verified_identifier?(scoped_id, scope: "different.com")
118
+ #=> false
119
+
120
+ ## Scoped identifiers fail verification without scope parameter
121
+ scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
122
+ Familia::VerifiableIdentifier.verified_identifier?(scoped_id)
123
+ #=> false
124
+
125
+ ## Unscoped identifiers fail verification with scope parameter
126
+ unscoped_id = Familia::VerifiableIdentifier.generate_verifiable_id
127
+ Familia::VerifiableIdentifier.verified_identifier?(unscoped_id, scope: "example.com")
128
+ #=> false
129
+
130
+ ## Empty string scope produces different identifier than nil scope
131
+ id_nil = Familia::VerifiableIdentifier.generate_verifiable_id(scope: nil)
132
+ id_empty = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "")
133
+ id_nil != id_empty
134
+ #=> true
135
+
136
+ ## Empty string scope verifies correctly
137
+ id_empty = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "")
138
+ Familia::VerifiableIdentifier.verified_identifier?(id_empty, scope: "")
139
+ #=> true
140
+
141
+ ## Short scope values work correctly
142
+ id_short = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "a")
143
+ Familia::VerifiableIdentifier.verified_identifier?(id_short, scope: "a")
144
+ #=> true
145
+
146
+ ## Long scope values work correctly
147
+ long_scope = "x" * 1000
148
+ id_long = Familia::VerifiableIdentifier.generate_verifiable_id(scope: long_scope)
149
+ Familia::VerifiableIdentifier.verified_identifier?(id_long, scope: long_scope)
150
+ #=> true
151
+
152
+ ## Unicode scope values work correctly
153
+ unicode_scope = "测试🔒🔑"
154
+ id_unicode = Familia::VerifiableIdentifier.generate_verifiable_id(scope: unicode_scope)
155
+ Familia::VerifiableIdentifier.verified_identifier?(id_unicode, scope: unicode_scope)
156
+ #=> true
157
+
158
+ ## Scoped identifiers work with different bases
159
+ id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "test", base: 16)
160
+ Familia::VerifiableIdentifier.verified_identifier?(id_hex, scope: "test", base: 16)
161
+ #=> true
162
+
163
+ ## Backward compatibility: existing method signatures still work
164
+ id = Familia::VerifiableIdentifier.generate_verifiable_id(36)
165
+ Familia::VerifiableIdentifier.verified_identifier?(id, 36)
166
+ #=> true
167
+
168
+ ## Mixed parameter styles work correctly
169
+ id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "test", base: 16)
170
+ Familia::VerifiableIdentifier.verified_identifier?(id, scope: "test", base: 16)
171
+ #=> true