authforge-sdk 1.2.0__tar.gz → 1.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authforge-sdk
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
5
  Author: AuthForge
6
6
  License: MIT
@@ -110,7 +110,7 @@ client = AuthForgeClient(
110
110
  | Parameter | Type | Default | Description |
111
111
  |---|---|---|---|
112
112
  | `app_id` | str | required | Your application ID from the AuthForge dashboard |
113
- | `app_secret` | str | required | Your application secret from the AuthForge dashboard |
113
+ | `app_secret` | str | required for online APIs; `None` / `""` for `login_from_file` only | Your application secret from the AuthForge dashboard. Do not ship it in air-gapped binaries. |
114
114
  | `public_key` | `str \| Sequence[str]` | required | App Ed25519 public key(s) (base64) from dashboard. Pass one key, a list of keys, or a comma-separated string to trust multiple keys during rotation (see [Key rotation](#key-rotation)). |
115
115
  | `heartbeat_mode` | `str \| None` | `None` | Deprecated shim (see [Migrating from heartbeat_mode](#migrating-from-heartbeat_mode)). Use `online_heartbeat` instead. |
116
116
  | `heartbeat_interval` | int | `900` | Seconds between background checks (minimum `10`; default 15 min). Applies to both grace period checks and online check-ins. |
@@ -159,7 +159,7 @@ Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped
159
159
 
160
160
  ## Offline license files (`.authforge`)
161
161
 
162
- For machines that never connect to the internet, the operator mints a **signed offline license file** in the AuthForge dashboard (License page -> *Mint .authforge file*) or via `POST /v1/licenses/{licenseKey}/offline-files`. The file is a standalone Ed25519-signed document; the SDK verifies it with **only** your app public key and the machine HWID. It never contacts AuthForge and never starts online check-ins.
162
+ For machines that never connect to the internet, the operator mints a **signed offline license file** in the AuthForge dashboard (License page -> *Mint .authforge file*) or via `POST /v1/licenses/{licenseKey}/offline-files`. The file is a standalone Ed25519-signed document; the SDK verifies it with **only** your app public key and the machine HWID. It never contacts AuthForge and never starts online check-ins. Pass `app_secret=None` (or `""`) so the air-gapped binary does not contain the App Secret.
163
163
 
164
164
  | | Grace period (default) | Offline license file |
165
165
  | --- | --- | --- |
@@ -174,13 +174,13 @@ from authforge import AuthForgeClient
174
174
 
175
175
  client = AuthForgeClient(
176
176
  app_id="YOUR_APP_ID",
177
- app_secret="YOUR_APP_SECRET", # unused for offline files but still required by the constructor
177
+ app_secret=None, # login_from_file does not use the App Secret; do not ship it in air-gapped builds
178
178
  public_key="YOUR_PUBLIC_KEY",
179
179
  on_failure=lambda reason, exc: print(reason, exc),
180
180
  )
181
181
 
182
- # 1. The customer sends you this value so you can bind the file to their machine:
183
- print("HWID:", client.get_hwid())
182
+ # 1. Write an activation request the operator drops into the mint dialog:
183
+ client.write_activation_request("machine.authforge-request")
184
184
 
185
185
  # 2. Later, authorize from the minted file (path or armored text). No network.
186
186
  if client.login_from_file("license.authforge"):
@@ -232,6 +232,8 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
232
232
  | `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
233
233
  | `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
234
234
  | `get_hwid()` | `str` | The HWID this client sends (or `hwid_override`); customers share it to receive a bound file |
235
+ | `create_activation_request(**kwargs)` | `str` | Unsigned `.authforge-request` for this machine. No network, no secret. Hostname omitted unless `include_machine_name=True` |
236
+ | `write_activation_request(path, **kwargs)` | `None` | Writes that file as UTF-8 |
235
237
  | `logout()` | `None` | Stops background checks and clears all session/auth state |
236
238
  | `is_authenticated()` | `bool` | True when an active authenticated session exists |
237
239
  | `get_session_data()` | `dict \| None` | Full decoded payload map |
@@ -82,7 +82,7 @@ client = AuthForgeClient(
82
82
  | Parameter | Type | Default | Description |
83
83
  |---|---|---|---|
84
84
  | `app_id` | str | required | Your application ID from the AuthForge dashboard |
85
- | `app_secret` | str | required | Your application secret from the AuthForge dashboard |
85
+ | `app_secret` | str | required for online APIs; `None` / `""` for `login_from_file` only | Your application secret from the AuthForge dashboard. Do not ship it in air-gapped binaries. |
86
86
  | `public_key` | `str \| Sequence[str]` | required | App Ed25519 public key(s) (base64) from dashboard. Pass one key, a list of keys, or a comma-separated string to trust multiple keys during rotation (see [Key rotation](#key-rotation)). |
87
87
  | `heartbeat_mode` | `str \| None` | `None` | Deprecated shim (see [Migrating from heartbeat_mode](#migrating-from-heartbeat_mode)). Use `online_heartbeat` instead. |
88
88
  | `heartbeat_interval` | int | `900` | Seconds between background checks (minimum `10`; default 15 min). Applies to both grace period checks and online check-ins. |
@@ -131,7 +131,7 @@ Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped
131
131
 
132
132
  ## Offline license files (`.authforge`)
133
133
 
134
- For machines that never connect to the internet, the operator mints a **signed offline license file** in the AuthForge dashboard (License page -> *Mint .authforge file*) or via `POST /v1/licenses/{licenseKey}/offline-files`. The file is a standalone Ed25519-signed document; the SDK verifies it with **only** your app public key and the machine HWID. It never contacts AuthForge and never starts online check-ins.
134
+ For machines that never connect to the internet, the operator mints a **signed offline license file** in the AuthForge dashboard (License page -> *Mint .authforge file*) or via `POST /v1/licenses/{licenseKey}/offline-files`. The file is a standalone Ed25519-signed document; the SDK verifies it with **only** your app public key and the machine HWID. It never contacts AuthForge and never starts online check-ins. Pass `app_secret=None` (or `""`) so the air-gapped binary does not contain the App Secret.
135
135
 
136
136
  | | Grace period (default) | Offline license file |
137
137
  | --- | --- | --- |
@@ -146,13 +146,13 @@ from authforge import AuthForgeClient
146
146
 
147
147
  client = AuthForgeClient(
148
148
  app_id="YOUR_APP_ID",
149
- app_secret="YOUR_APP_SECRET", # unused for offline files but still required by the constructor
149
+ app_secret=None, # login_from_file does not use the App Secret; do not ship it in air-gapped builds
150
150
  public_key="YOUR_PUBLIC_KEY",
151
151
  on_failure=lambda reason, exc: print(reason, exc),
152
152
  )
153
153
 
154
- # 1. The customer sends you this value so you can bind the file to their machine:
155
- print("HWID:", client.get_hwid())
154
+ # 1. Write an activation request the operator drops into the mint dialog:
155
+ client.write_activation_request("machine.authforge-request")
156
156
 
157
157
  # 2. Later, authorize from the minted file (path or armored text). No network.
158
158
  if client.login_from_file("license.authforge"):
@@ -204,6 +204,8 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
204
204
  | `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
205
205
  | `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
206
206
  | `get_hwid()` | `str` | The HWID this client sends (or `hwid_override`); customers share it to receive a bound file |
207
+ | `create_activation_request(**kwargs)` | `str` | Unsigned `.authforge-request` for this machine. No network, no secret. Hostname omitted unless `include_machine_name=True` |
208
+ | `write_activation_request(path, **kwargs)` | `None` | Writes that file as UTF-8 |
207
209
  | `logout()` | `None` | Stops background checks and clears all session/auth state |
208
210
  | `is_authenticated()` | `bool` | True when an active authenticated session exists |
209
211
  | `get_session_data()` | `dict \| None` | Full decoded payload map |
@@ -80,6 +80,21 @@ OFFLINE_LICENSE_ERRORS = (
80
80
  "hwid_mismatch",
81
81
  )
82
82
 
83
+ # Activation requests (`.authforge-request`): unsigned transport for a HWID.
84
+ # Distinct markers from BEGIN AUTHFORGE LICENSE. Not signed; the Checksum
85
+ # header is the only integrity check. Keep _SDK_TAG in sync with pyproject.toml.
86
+ _ACTIVATION_REQUEST_VERSION = 1
87
+ _ACTIVATION_REQUEST_TYP = "authforge-activation-request"
88
+ _BEGIN_ACTIVATION_REQUEST = "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----"
89
+ _END_ACTIVATION_REQUEST = "-----END AUTHFORGE ACTIVATION REQUEST-----"
90
+ _SDK_TAG = "python/1.2.1"
91
+ _ARMOR_LINE_WIDTH = 64
92
+ _MAX_REQUEST_HWID = 256
93
+ _MAX_REQUEST_MACHINE_NAME = 128
94
+ _MAX_REQUEST_OS = 64
95
+ _MAX_REQUEST_SDK = 64
96
+ _MAX_REQUEST_LICENSE_KEY = 64
97
+
83
98
 
84
99
  class OfflineLicense(TypedDict):
85
100
  app_id: str
@@ -117,6 +132,131 @@ class ParsedLicenseFile(TypedDict):
117
132
  signature_base64: str
118
133
 
119
134
 
135
+ def _clip_request_field(value: str, max_len: int) -> str:
136
+ return value if len(value) <= max_len else value[:max_len]
137
+
138
+
139
+ def _json_escape_request(value: str) -> str:
140
+ out: List[str] = []
141
+ for ch in value:
142
+ code = ord(ch)
143
+ if ch == "\\":
144
+ out.append("\\\\")
145
+ elif ch == '"':
146
+ out.append('\\"')
147
+ elif ch == "\b":
148
+ out.append("\\b")
149
+ elif ch == "\f":
150
+ out.append("\\f")
151
+ elif ch == "\n":
152
+ out.append("\\n")
153
+ elif ch == "\r":
154
+ out.append("\\r")
155
+ elif ch == "\t":
156
+ out.append("\\t")
157
+ elif code < 0x20:
158
+ out.append(f"\\u00{code:02x}")
159
+ else:
160
+ out.append(ch)
161
+ return '"' + "".join(out) + '"'
162
+
163
+
164
+ def _wrap_armor_64(value: str) -> str:
165
+ return "\n".join(value[i : i + _ARMOR_LINE_WIDTH] for i in range(0, len(value), _ARMOR_LINE_WIDTH))
166
+
167
+
168
+ def _canonical_activation_request_json(
169
+ *,
170
+ app_id: str,
171
+ hwid: str,
172
+ created_at: str,
173
+ machine_name: Optional[str] = None,
174
+ os_name: Optional[str] = None,
175
+ sdk: Optional[str] = None,
176
+ license_key: Optional[str] = None,
177
+ ) -> str:
178
+ parts = [
179
+ f'"v":{_ACTIVATION_REQUEST_VERSION}',
180
+ f'"typ":{_json_escape_request(_ACTIVATION_REQUEST_TYP)}',
181
+ f'"appId":{_json_escape_request(app_id)}',
182
+ f'"hwid":{_json_escape_request(_clip_request_field(hwid, _MAX_REQUEST_HWID))}',
183
+ f'"createdAt":{_json_escape_request(created_at)}',
184
+ ]
185
+ if machine_name:
186
+ parts.append(
187
+ f'"machineName":{_json_escape_request(_clip_request_field(machine_name, _MAX_REQUEST_MACHINE_NAME))}'
188
+ )
189
+ if os_name:
190
+ parts.append(f'"os":{_json_escape_request(_clip_request_field(os_name, _MAX_REQUEST_OS))}')
191
+ if sdk:
192
+ parts.append(f'"sdk":{_json_escape_request(_clip_request_field(sdk, _MAX_REQUEST_SDK))}')
193
+ if license_key:
194
+ parts.append(
195
+ f'"licenseKey":{_json_escape_request(_clip_request_field(license_key, _MAX_REQUEST_LICENSE_KEY))}'
196
+ )
197
+ return "{" + ",".join(parts) + "}"
198
+
199
+
200
+ def _utc_iso_ms(value: Optional[datetime] = None) -> str:
201
+ now = value or datetime.now(timezone.utc)
202
+ if now.tzinfo is None:
203
+ now = now.replace(tzinfo=timezone.utc)
204
+ now = now.astimezone(timezone.utc)
205
+ return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(now.microsecond / 1000):03d}Z"
206
+
207
+
208
+ def _detect_os_label() -> str:
209
+ system = platform.system()
210
+ release = platform.release()
211
+ if system == "Darwin":
212
+ mac = platform.mac_ver()[0]
213
+ label = f"macOS {mac or release}"
214
+ elif system == "Windows":
215
+ label = f"Windows {release}"
216
+ elif system == "Linux":
217
+ label = f"Linux {release}"
218
+ else:
219
+ label = f"{system} {release}".strip()
220
+ return _clip_request_field(label, _MAX_REQUEST_OS)
221
+
222
+
223
+ def format_activation_request(
224
+ *,
225
+ app_id: str,
226
+ hwid: str,
227
+ created_at: str,
228
+ machine_name: Optional[str] = None,
229
+ os: Optional[str] = None,
230
+ sdk: Optional[str] = None,
231
+ license_key: Optional[str] = None,
232
+ ) -> str:
233
+ """Armored ``.authforge-request`` text from explicit fields."""
234
+ json_body = _canonical_activation_request_json(
235
+ app_id=app_id,
236
+ hwid=hwid,
237
+ created_at=created_at,
238
+ machine_name=machine_name,
239
+ os_name=os,
240
+ sdk=sdk,
241
+ license_key=license_key,
242
+ )
243
+ payload_b64 = base64.b64encode(json_body.encode("utf-8")).decode("ascii")
244
+ checksum = hashlib.sha256(payload_b64.encode("utf-8")).hexdigest()[:16]
245
+ clean_app = app_id.replace("\r", " ").replace("\n", " ").strip()
246
+ return "\n".join(
247
+ [
248
+ _BEGIN_ACTIVATION_REQUEST,
249
+ f"Version: {_ACTIVATION_REQUEST_VERSION}",
250
+ f"App-Id: {clean_app}",
251
+ f"Checksum: {checksum}",
252
+ "",
253
+ _wrap_armor_64(payload_b64),
254
+ _END_ACTIVATION_REQUEST,
255
+ "",
256
+ ]
257
+ )
258
+
259
+
120
260
  def parse_license_file(text: str) -> Optional[ParsedLicenseFile]:
121
261
  """Parse armored ``.authforge`` text.
122
262
 
@@ -360,7 +500,7 @@ class AuthForgeClient:
360
500
  def __init__(
361
501
  self,
362
502
  app_id: str,
363
- app_secret: str,
503
+ app_secret: Optional[str],
364
504
  public_key: PublicKeyArg,
365
505
  heartbeat_mode: Optional[str] = None,
366
506
  heartbeat_interval: int = 900,
@@ -374,8 +514,12 @@ class AuthForgeClient:
374
514
  ) -> None:
375
515
  if not app_id or not isinstance(app_id, str):
376
516
  raise ValueError("app_id must be a non-empty string")
377
- if not app_secret or not isinstance(app_secret, str):
378
- raise ValueError("app_secret must be a non-empty string")
517
+ # Empty/None is valid for offline-only clients (login_from_file).
518
+ # Online APIs (login, validate_license, self_ban) still require a secret.
519
+ if app_secret is None:
520
+ app_secret = ""
521
+ elif not isinstance(app_secret, str):
522
+ raise ValueError("app_secret must be a string or None")
379
523
  public_key_list = self._normalize_public_key_list(public_key)
380
524
  if not public_key_list:
381
525
  raise ValueError(
@@ -459,6 +603,49 @@ class AuthForgeClient:
459
603
  """
460
604
  return self._hwid
461
605
 
606
+ def create_activation_request(
607
+ self,
608
+ *,
609
+ include_machine_name: bool = False,
610
+ machine_name: Optional[str] = None,
611
+ os: Optional[str] = None,
612
+ omit_os: bool = False,
613
+ sdk: Optional[str] = None,
614
+ omit_sdk: bool = False,
615
+ license_key: Optional[str] = None,
616
+ created_at: Optional[str] = None,
617
+ ) -> str:
618
+ """Build an activation request (``.authforge-request``) for this machine.
619
+
620
+ No network, no session, no app secret. The HWID is the same value
621
+ :meth:`login` / :meth:`login_from_file` use. ``machine_name`` is omitted
622
+ unless ``include_machine_name`` is true (hostnames are often a person's
623
+ name).
624
+ """
625
+ created = created_at if created_at else _utc_iso_ms()
626
+ name: Optional[str] = None
627
+ if include_machine_name:
628
+ name = machine_name or platform.node() or socket.gethostname()
629
+ os_name: Optional[str] = None if omit_os else (os if os is not None else _detect_os_label())
630
+ sdk_tag: Optional[str] = None if omit_sdk else (sdk if sdk is not None else _SDK_TAG)
631
+ key = license_key if license_key is not None else self._license_key
632
+ return format_activation_request(
633
+ app_id=self.app_id,
634
+ hwid=self._hwid,
635
+ created_at=created,
636
+ machine_name=name,
637
+ os=os_name,
638
+ sdk=sdk_tag,
639
+ license_key=key,
640
+ )
641
+
642
+ def write_activation_request(self, path: str, **kwargs: Any) -> None:
643
+ """Write an activation request to ``path`` (UTF-8). Same kwargs as
644
+ :meth:`create_activation_request`."""
645
+ text = self.create_activation_request(**kwargs)
646
+ with open(path, "w", encoding="utf-8", newline="\n") as handle:
647
+ handle.write(text)
648
+
462
649
  def login_from_file(self, path_or_text: str) -> bool:
463
650
  """Authorize from a cloud-minted offline license file (``.authforge``)
464
651
  with NO network access. Accepts a filesystem path or the armored text.
@@ -558,9 +745,16 @@ class AuthForgeClient:
558
745
  except Exception:
559
746
  pass
560
747
 
748
+ def _require_app_secret(self) -> None:
749
+ if not self.app_secret:
750
+ raise ValueError(
751
+ "app_secret is required for online APIs; omit it only when using login_from_file"
752
+ )
753
+
561
754
  def login(self, license_key: str) -> bool:
562
755
  if not license_key or not isinstance(license_key, str):
563
756
  raise ValueError("license_key must be a non-empty string")
757
+ self._require_app_secret()
564
758
 
565
759
  try:
566
760
  self._validate_and_store(license_key)
@@ -575,6 +769,7 @@ class AuthForgeClient:
575
769
  session state or starting the heartbeat thread."""
576
770
  if not license_key or not isinstance(license_key, str):
577
771
  raise ValueError("license_key must be a non-empty string")
772
+ self._require_app_secret()
578
773
  try:
579
774
  body: Dict[str, Any] = {
580
775
  "appId": self.app_id,
@@ -661,6 +856,7 @@ class AuthForgeClient:
661
856
  resolved_license = explicit_license or current_license
662
857
  if not resolved_license:
663
858
  raise ValueError("missing_license_key")
859
+ self._require_app_secret()
664
860
 
665
861
  body = {
666
862
  "appId": self.app_id,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authforge-sdk
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
5
  Author: AuthForge
6
6
  License: MIT
@@ -110,7 +110,7 @@ client = AuthForgeClient(
110
110
  | Parameter | Type | Default | Description |
111
111
  |---|---|---|---|
112
112
  | `app_id` | str | required | Your application ID from the AuthForge dashboard |
113
- | `app_secret` | str | required | Your application secret from the AuthForge dashboard |
113
+ | `app_secret` | str | required for online APIs; `None` / `""` for `login_from_file` only | Your application secret from the AuthForge dashboard. Do not ship it in air-gapped binaries. |
114
114
  | `public_key` | `str \| Sequence[str]` | required | App Ed25519 public key(s) (base64) from dashboard. Pass one key, a list of keys, or a comma-separated string to trust multiple keys during rotation (see [Key rotation](#key-rotation)). |
115
115
  | `heartbeat_mode` | `str \| None` | `None` | Deprecated shim (see [Migrating from heartbeat_mode](#migrating-from-heartbeat_mode)). Use `online_heartbeat` instead. |
116
116
  | `heartbeat_interval` | int | `900` | Seconds between background checks (minimum `10`; default 15 min). Applies to both grace period checks and online check-ins. |
@@ -159,7 +159,7 @@ Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped
159
159
 
160
160
  ## Offline license files (`.authforge`)
161
161
 
162
- For machines that never connect to the internet, the operator mints a **signed offline license file** in the AuthForge dashboard (License page -> *Mint .authforge file*) or via `POST /v1/licenses/{licenseKey}/offline-files`. The file is a standalone Ed25519-signed document; the SDK verifies it with **only** your app public key and the machine HWID. It never contacts AuthForge and never starts online check-ins.
162
+ For machines that never connect to the internet, the operator mints a **signed offline license file** in the AuthForge dashboard (License page -> *Mint .authforge file*) or via `POST /v1/licenses/{licenseKey}/offline-files`. The file is a standalone Ed25519-signed document; the SDK verifies it with **only** your app public key and the machine HWID. It never contacts AuthForge and never starts online check-ins. Pass `app_secret=None` (or `""`) so the air-gapped binary does not contain the App Secret.
163
163
 
164
164
  | | Grace period (default) | Offline license file |
165
165
  | --- | --- | --- |
@@ -174,13 +174,13 @@ from authforge import AuthForgeClient
174
174
 
175
175
  client = AuthForgeClient(
176
176
  app_id="YOUR_APP_ID",
177
- app_secret="YOUR_APP_SECRET", # unused for offline files but still required by the constructor
177
+ app_secret=None, # login_from_file does not use the App Secret; do not ship it in air-gapped builds
178
178
  public_key="YOUR_PUBLIC_KEY",
179
179
  on_failure=lambda reason, exc: print(reason, exc),
180
180
  )
181
181
 
182
- # 1. The customer sends you this value so you can bind the file to their machine:
183
- print("HWID:", client.get_hwid())
182
+ # 1. Write an activation request the operator drops into the mint dialog:
183
+ client.write_activation_request("machine.authforge-request")
184
184
 
185
185
  # 2. Later, authorize from the minted file (path or armored text). No network.
186
186
  if client.login_from_file("license.authforge"):
@@ -232,6 +232,8 @@ A desktop app running 6h/day with online check-ins at a 15-minute interval burns
232
232
  | `get_offline_license()` | `dict \| None` | Metadata of the offline file in use (`jti`, `expires_at`, `hwid_policy`, …) |
233
233
  | `get_session_kind()` | `"online" \| "offline" \| None` | Which kind of session the client holds (`None` when logged out) |
234
234
  | `get_hwid()` | `str` | The HWID this client sends (or `hwid_override`); customers share it to receive a bound file |
235
+ | `create_activation_request(**kwargs)` | `str` | Unsigned `.authforge-request` for this machine. No network, no secret. Hostname omitted unless `include_machine_name=True` |
236
+ | `write_activation_request(path, **kwargs)` | `None` | Writes that file as UTF-8 |
235
237
  | `logout()` | `None` | Stops background checks and clears all session/auth state |
236
238
  | `is_authenticated()` | `bool` | True when an active authenticated session exists |
237
239
  | `get_session_data()` | `dict \| None` | Full decoded payload map |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "authforge-sdk"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes