Skip to content

fix(licensing): unblock CI + v2 aidn2 hardening (P1) — capability gating, CRL, scope/machine binding - #1891

Merged
ooples merged 24 commits into
masterfrom
fix/license-v2-validation
Jul 18, 2026
Merged

ooples merged 24 commits into
masterfrom
fix/license-v2-validation

Conversation

@ooples

@ooples ooples commented Jul 18, 2026 •

Copy link
Copy Markdown
Owner

Why

CI's license-gated persistence tests (LicensingIntegrationTests.*, ExpressionTree_Serialize*, InferenceSession…Serialize*) went red after #1883 started passing AIDOTNET_LICENSE_KEY into the test jobs. Root cause: the secret is a community AIDN-PROD-COMMUNITY-* online-only server key that does not lift the free-trial operation cap, so the shared trial budget drains to 0 and every SaveEncrypted/Load that expects success throws LicenseRequiredException. Before #1883, ModuleInitializer injected a synthetic offline key and CI was green.

Deeper: v2 aidn2 (asymmetric Ed25519) was code-complete but never operationalized — release CI injects BuildKey.bin but not LicensePublicKey.json, there's no issuer, and the site still mints AIDN-*. v1's symmetric HMAC build key ships in every DLL (reverse-engineerable → forgeable), which is why v2 exists.

What (P1 — client SDK; unblocks CI)

  • ModuleInitializer hardening (the CI unblock): install a deterministic synthetic offline test license unless the env key is itself offline-verifiable (aidn2/aidn.). A server-only community AIDN-* env key — which forces the whole suite online and won't lift the save cap under capability gating — is replaced, so the persistence-heavy suite is reliably licensed. The real online/community-key path stays covered by LicenseE2ETests.
  • Capability-authoritative gating (ModelPersistenceGuard + LicenseCapabilities): an Active license lifts the cap for an op only when it grants the required capability (model:save for save/serialize; load stays lenient). No-caps tokens = legacy grant-all (migration bridge); Active-but-missing-cap falls through to the trial (community keeps its free-trial saves). This is the paid-conversion gate (save/persist/encrypt), GPU stays free.
  • aidn2 hardening: LicenseClaims gains jti (revocation id), caps (authoritative offline grants), mach (node-lock), scope (audience). AsymmetricLicenseVerifier enforces CRL revocation + machine + scope binding after signature verify, and carries signed caps into the result. New LicenseRevocationProvider verifies a signed CRL (embedded + online-fetched) and denies revoked kid/jti.
  • Reference aidn2 issuer (tools/license-issuer) + full design doc (docs/licensing/v2-licensing-design.md).
  • Fixes a pre-existing stale test (StripeWebhook_LicenseKeyFormat_IsValid) to assert the actual AIDN-PROD-{TIER}-{32hex} webhook format (was asserting the retired aidn.{12}.{16} form that fix(license): require a 32-byte signature to classify aidn. keys as offline-HMAC #1807 rejects).

Validation

With AIDOTNET_LICENSE_KEY=AIDN-PROD-COMMUNITY-* (the exact CI scenario), the previously-failing LicensingIntegrationTests pass 7/7 (incl. the encrypt round-trips); broader license suite green apart from unrelated pre-existing failures. src builds clean.

Status of the design §13 follow-ups

  • P1 mirror in AiDotNet.Tensors — ✅ done (feat(licensing): P1 mirror — scope binding + jti/CRL revocation on signed entitlements AiDotNet.Tensors#808): capability-authoritative guard + aidn2 scope/machine/CRL, the "Both" offline model (per-tenant RSA entitlement activated + per-machine aidn2 parity), and standalone client glue.
  • P2 server — ✅ done: issue-license + get-revocations edge functions deployed (server signs aidn2 / the signed CRL), revocations table + revoke_license(). validate_license_key already returns license_id (the jti) and per-tier capabilities, so no hot-path change was needed.
  • P3 CI key — ✅ done: dedicated ci-2026a keypair, its public key embedded alongside prod-2026a (multi-kid bundle), a scope-fenced short-exp aidn2 token in its own AIDOTNET_CI_LICENSE_KEY secret with AIDOTNET_LICENSE_SCOPE=ci wired into the test workflows (isolated from the global secret); rotation documented.
  • P4 rollout — operational/staged in docs/licensing/v2-deploy-runbook.md (re-issue paid keys with caps, publish CRL, deprecate AIDN-* after 90 days).
  • Stale-branch rebase — investigated: the failing shards fail on master too (pre-existing, not license-related), so a rebase onto current master doesn't fix them. Clean path: merge this PR to master, then branch authors merge master; nothing force-pushed.

(Review follow-ups from CodeRabbit — CRL merge/expiry, ModuleInitializer offline-verify, issuer/keygen hardening, edge-fn validation + opaque sub, migration FK/idempotency — all addressed in e9ec13fc7.)

Summary by CodeRabbit

  • New Features

    • Added support for short-lived offline license tokens with capability-based access.
    • Added optional machine and scope binding for stronger license validation.
    • Added signed license revocation support, including online refresh and offline enforcement.
    • Added tools and service endpoints for issuing licenses and retrieving revocation updates.
  • Bug Fixes

    • License validation now rejects revoked, machine-mismatched, or scope-mismatched tokens.
  • Documentation

    • Added deployment, rollout, migration, and rollback guidance for the updated licensing system.

ooples and others added 5 commits July 17, 2026 21:15
- docs/licensing/v2-licensing-design.md: full v2 design for review — hybrid
  offline/online (short exp + attestation + grace), CRL deny-list revocation,
  server-side-only Ed25519 signing, scope/machine binding, capability-based
  paid-tier gating (persistence + encrypted-model IP + air-gapped/seats; GPU
  stays free), and the CI unblock (short-lived scoped offline aidn2 + online
  fallback + public-key embed in the build).
- tools/license-issuer/aidn2_issuer.py: reference issuer that mints aidn2
  Ed25519 tokens + emits the public JWK (production signing moves to a
  server-side edge function; private key never ships).

No key material committed (generated artifacts go to a scratch dir).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- LicenseClaims: add jti (revocation target), caps (authoritative offline
  capability grants), mach (node-lock), scope (audience binding).
- AsymmetricLicenseVerifier: after signature verify, enforce CRL revocation
  (kid/jti), machine binding (mach == local machine id hash), and scope binding
  (scope == AIDOTNET_LICENSE_SCOPE); carry signed caps into the Active result.
- LicenseRevocationProvider (new): JWS-style signed CRL (embedded + online-fetched),
  Ed25519-verified against the embedded public key, expiry-checked, fail-open.
- csproj: embed optional BuildKey/LicenseRevocation.json as AiDotNet.LicenseRevocation.

src builds clean. Client-side P1 per docs/licensing/v2-licensing-design.md §13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t hardening

- LicenseCapabilities: canonical capability ids (model:save/load, tensors:save/load,
  model:encrypt, offline) shared by server caps[], token caps claim, and guards.
- ModelPersistenceGuard: EnforceCore now takes a required capability. An Active
  license lifts the cap for an op only when it grants that capability; a token with
  NO caps is legacy grant-all (migration bridge); an Active license lacking the cap
  falls through to trial (community keeps free-trial saves). Save/serialize require
  model:save (paid gate); load/deserialize stay lenient (any Active).
- ModuleInitializer: install the synthetic OFFLINE test license unless the env key is
  itself offline-verifiable (aidn2/aidn.). A server-only AIDN-* (community) env key —
  which forces the whole suite online and won't lift the save cap under capability
  gating — is replaced with the offline test license so the persistence-heavy suite is
  deterministically licensed. The real online/community-key path stays in LicenseE2ETests.

Validated: with a simulated AIDN-PROD-COMMUNITY-* env key (the exact CI scenario), the
previously-failing LicensingIntegrationTests pass 7/7 (incl. the encrypt round-trips);
no new regressions in the license suite (10 unrelated pre-existing failures: 9 causal-
discovery name-coincidence, 1 stale stripe-format test from #1807).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-{TIER}-{32hex}

The webhook emits server-validated AIDN-* keys (WEBHOOK_PREFIX=AIDN-PROD), not the
old aidn.{id}.{sig} form the test asserted — which #1807's 32-byte-signature rule
now rejects. Assert the actual format the webhook produces (pre-existing red on master).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/franklins-projects-02a0b5a0?upgradeToPro=build-rate-limit

@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8d052200-0c96-4f68-9a3e-c46504323c63

📥 Commits

Reviewing files that changed from the base of the PR and between f318177 and e3ce888.

📒 Files selected for processing (25)
  • .github/workflows/heavy-timeout-nightly.yml
  • .github/workflows/release-please.yml
  • .github/workflows/sonarcloud.yml
  • docs/licensing/v2-deploy-runbook.md
  • docs/licensing/v2-licensing-design.md
  • src/BuildKey/LicensePublicKey.json
  • src/Helpers/LicenseRevocationProvider.cs
  • src/Helpers/LicenseValidator.cs
  • src/Helpers/OnlineLicenseServices.cs
  • tests/AiDotNet.Tests/Helpers/AsymmetricLicenseBindingTests.cs
  • tests/AiDotNet.Tests/Helpers/LicenseTestSupport.cs
  • tests/AiDotNet.Tests/Helpers/OnlineLicenseServicesTests.cs
  • tests/AiDotNet.Tests/ModuleInitializer.cs
  • tools/license-issuer/aidn2_issuer.py
  • tools/license-issuer/keygen.py
  • website/src/pages/pricing.astro
  • website/supabase/functions/_shared/aidn2.ts
  • website/supabase/functions/get-revocations/index.ts
  • website/supabase/functions/issue-license/index.ts
  • website/supabase/functions/register-community-license/index.ts
  • website/supabase/migrations/20260610000000_log_validations_to_api_usage.sql
  • website/supabase/migrations/20260718000000_revocations.sql
  • website/supabase/migrations/20260718000100_lock_revoke_license_execute.sql
  • website/supabase/migrations/20260718000200_fix_revocations_fk_and_idempotency.sql
  • website/supabase/migrations/20260718000300_fix_validate_license_advisory_lock_regression.sql

Walkthrough

The PR defines the aidn2 licensing design and rollout, adds signed claim and CRL verification, enforces capabilities for persistence, introduces Supabase issuance and revocation services, adds Ed25519 tooling, and injects verification resources into CI builds.

Changes

aidn2 licensing

Layer / File(s) Summary
Design and rollout procedures
docs/licensing/*
Documents aidn2 claims, revocation, capability gating, issuance, CI setup, migration, deployment, and rollback procedures.
SDK claims, verification, and CRL enforcement
src/Helpers/LicenseClaims.cs, src/Helpers/LicenseCapabilities.cs, src/Helpers/LicenseRevocationProvider.cs, src/Helpers/AsymmetricLicenseVerifier.cs, src/AiDotNet.csproj
Adds signed claim fields, capability identifiers, CRL parsing and installation, binding checks, capability propagation, and optional embedded CRL resources.
Capability-aware persistence and validation tests
src/Helpers/ModelPersistenceGuard.cs, tests/AiDotNet.Tests/Helpers/*
Applies capability checks to persistence operations and adds coverage for bindings, revocation, capabilities, key formats, test initialization, and CRL fixtures.
Server issuance and revocation flow
website/supabase/functions/*, website/supabase/migrations/*
Adds signed token and CRL endpoints, tier capability mapping, revocation storage, protected revocation RPC access, and database-backed deny-list generation.
Key tooling and CI embedding
tools/license-issuer/*, .github/workflows/sonarcloud.yml
Adds Ed25519 keypair and token-generation CLIs and injects configured public-key and CRL files into CI builds.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant issue-license
  participant Supabase
  participant aidn2Signer
  Client->>issue-license: Submit license key and machine hash
  issue-license->>Supabase: Validate license through RPC
  Supabase-->>issue-license: Return tier, license id, and capabilities
  issue-license->>aidn2Signer: Sign aidn2 claims
  aidn2Signer-->>Client: Return offline token
Loading

Possibly related PRs

Poem

Ed25519 keys begin to sing,
CRLs guard each offline thing.
Claims bind scope, machine, and caps,
CI builds avoid license traps.
Supabase signs the tokens bright—
aidn2 carries them through the night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: CI unblocking plus v2 aidn2 licensing hardening with capability gating, CRL, and scope/machine binding.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/license-v2-validation

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Commit messages auto-fixed

One or more commit messages did not follow Conventional Commits, so they were rewritten to comply (subject case, header length ≤ 100, valid type). Each commit and its diff were preserved — no squashing.

The branch was force-pushed with the corrected messages. If you have local work on this branch, run git pull --rebase (or reset to the remote) before pushing again.

Adds a step to the Build job that writes src/BuildKey/LicensePublicKey.json and
LicenseRevocation.json from repo vars/secrets (AIDOTNET_LICENSE_PUBLIC_KEY_JSON /
AIDOTNET_LICENSE_REVOCATION_JSON) so the CI-built AiDotNet.dll can verify offline
aidn2 tokens and honour the CRL. Both are PUBLIC/signed and safe to expose. No-op when
unset (embed is Condition="Exists(...)"). Forward-looking P1 wiring for the CI aidn2 key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
aidotnet-playground-api Ready Ready Preview, Comment Jul 18, 2026 2:18pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
aidotnet_website Ignored Ignored Preview Jul 18, 2026 2:18pm

ooples and others added 3 commits July 18, 2026 01:00
…aps flow-through

New AsymmetricLicenseBindingTests (10, all green): caps carried into the result;
scope binding rejected when host scope unset/differs and accepted when it matches
(no-scope tokens unaffected); machine binding rejected on a different machine and
accepted on the matching hash; signed-CRL revocation rejects a revoked jti, passes a
non-revoked one, and ignores an expired (stale) CRL. Extends LicenseTestSupport with
jti/caps/mach/scope params on SignedKeyV2 and a SignedCrlV2 helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…evocations, revocations)

Adds the server side of the v2 aidn2 offline-licensing model:

- _shared/aidn2.ts: Ed25519 aidn2 token + CRL signer (private key stays a
  server secret; caps map kept byte-identical to the validate_license_key RPC
  so online and offline validation grant the same capabilities).
- issue-license: online->offline bridge. Re-validates through validate_license_key
  (seat-checked, authoritative tier/license_id), then mints a short-exp,
  machine-bound aidn2 token the SDK caches + verifies offline.
- get-revocations: serves the signed CRL the SDK enforces offline (jti = license id).
- revocations table + revoke_license(uuid,text) helper that revokes on BOTH the
  online (status=revoked) and offline (CRL) paths atomically. Additive; applied to
  prod (yfkqwpgjahoamlgckjib) and locked to service_role (security-advisor clean).
- tools/license-issuer/keygen.py: one-shot keypair bootstrap emitting the exact
  Supabase secret + CI variable + JWK (private key never leaves the operator's box).
- docs/licensing/v2-deploy-runbook.md: ordered, reversible rollout.

Edge functions 503 until AIDOTNET_LICENSE_SIGNING_KEY_PKCS8 is set, so deploying
them + embedding the public key + the SDK auto-fetch glue are the runbook's
remaining human-gated steps. validate_license_key already returns license_id/caps,
so the live hot-path function is untouched and paid customers are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Helpers/ModelPersistenceGuard.cs (1)

262-291: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

BLOCKING: Apply capability gating to ValidationPending licenses too.

The new capability contract only covers Active. A server key with a valid timestamp attestation enters ValidationPending during an outage and returns successfully regardless of requiredCapability, bypassing model:save. Persist attested capabilities, or fail closed for capability-gated operations.

Safe fail-closed fix
                 case LicenseKeyStatus.ValidationPending:
-                    if (OnlineValidationAttestation.HasValidWithin(
+                    if (string.IsNullOrEmpty(requiredCapability) &&
+                        OnlineValidationAttestation.HasValidWithin(
                             licenseKey, OnlineValidationAttestation.AttestationValidity))
                     {
                         LicensingTelemetryCollector.Instance.RecordLicensedOperation("persistence");
                         return;
                     }

As per path instructions, half-implemented patterns where some paths omit enforcement are blocking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Helpers/ModelPersistenceGuard.cs` around lines 262 - 291, Update
EnforceCore and the ValidationPending handling in ValidateLicenseKey so a
ValidationPending license cannot bypass requiredCapability checks. Preserve and
use attested capabilities when available; otherwise fail closed for
capability-gated operations such as model:save, while retaining only the
explicitly permitted behavior for ungated operations.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/sonarcloud.yml:
- Around line 268-275: Update the LicensePublicKey.json and
LicenseRevocation.json injection blocks in the workflow to receive the GitHub
variable/secret values through environment variables, then have Bash read those
variables when checking presence and writing JSON. Do not interpolate the JSON
expressions directly inside shell commands; preserve the existing skip messages
and file-size reporting.

In `@docs/licensing/v2-deploy-runbook.md`:
- Around line 95-100: Remove the `drop table public.revocations` operation from
the Rollback section while retaining the endpoint/function rollback guidance.
State that revocation and audit data must be preserved, and direct any required
schema changes through a forward migration rather than destructive rollback.

In `@docs/licensing/v2-licensing-design.md`:
- Line 84: Update the revocation-list description to document the envelope
emitted by get-revocations: an outer object containing kid, payload, and sig,
with the signed payload using rkids and rjti fields. Remove the incompatible
inline revoked_kids/revoked_jti schema while preserving the signing and
verification details.

In `@src/Helpers/LicenseRevocationProvider.cs`:
- Around line 68-72: Update the synchronization logic in
LicenseRevocationProvider around _fetched so same-second additive CRL updates
are accepted instead of rejected by the candidate.Iat <= _fetched.Iat check.
Preserve rejection of older replayed CRLs, and either track a monotonic sequence
for ordering or merge the deny-lists when timestamps are equal so newly revoked
tokens remain blocked.
- Around line 166-173: Update the CRL parsing and installation flow around
TryInstallFetched to validate positive exp values numerically, reject
out-of-range timestamps without calling FromUnixTimeSeconds, and preserve the
expiry on the constructed Crl instead of discarding it. Extend Crl and update
Effective() to ignore cached CRLs whose positive expiry is before
DateTimeOffset.UtcNow, while retaining exp == 0 as non-expiring.

In `@tests/AiDotNet.Tests/ModuleInitializer.cs`:
- Around line 79-92: Update the _envKeyOfflineUsable calculation in
ModuleInitializer to perform actual offline validation of _envLicenseKey rather
than relying on IsAsymmetricKeyFormat or IsSignedKeyFormat. Require validation
to return an Active result with model:save capability, or accept legacy keys
with empty capabilities; otherwise retain the synthetic offline-license setup
for missing, invalid, expired, mismatched, or capability-limited keys.

In `@tools/license-issuer/aidn2_issuer.py`:
- Around line 39-52: Update build_claims and its callers to always emit a unique
jti, constrained caps, and CI-bound scope claims, preventing absent fields from
triggering legacy grant-all or unbound behavior. Change the issuer’s token
lifetime default and validation to enforce a maximum of 30 days, including the
argument/configuration handling referenced by the other affected locations.
- Around line 96-103: Update the private-key creation flow in the block guarded
by args.private_key_pem so private_key.pem is created with owner-only
permissions explicitly, rather than relying on the process umask; preserve the
existing PEM serialization and writing behavior.

In `@tools/license-issuer/keygen.py`:
- Around line 61-63: Update keygen.py’s JWK output flow to support append/merge
mode: load existing keys when requested, preserve them, and reject duplicate kid
values before writing the bundle. In docs/licensing/v2-licensing-design.md lines
105-108, identify the merged keys[] artifact as the release input. In
docs/licensing/v2-deploy-runbook.md lines 62-67, instruct operators to merge
both generated public keys before embedding the bundle or setting the CI
variable.
- Around line 53-63: Ensure the CI token uses the key generated by keygen rather
than the issuer’s fresh default: in tools/license-issuer/keygen.py lines 53-63,
emit a usable private-key handoff artifact or command; in
tools/license-issuer/aidn2_issuer.py lines 69-76, accept keygen’s base64 DER
PKCS#8 output or standardize both tools on PEM; and in
docs/licensing/v2-deploy-runbook.md lines 78-87, pass the existing CI private
key explicitly to aidn2_issuer.py.

In `@website/supabase/functions/_shared/aidn2.ts`:
- Line 61: Update the signing-key identifier initialization near kid and its
duplicate occurrence to require AIDOTNET_LICENSE_KID explicitly instead of
falling back to "prod-2026a"; fail immediately with a clear configuration error
when the environment variable is missing or empty, ensuring generated tokens and
CRLs use only the configured key identifier.
- Around line 60-68: Update signAidn2Token to validate c.expDays before
constructing claims or signing: reject NaN, non-positive, non-integer, and
excessive values, using the applicable maximum lifetime for tokens and CRLs.
Ensure invalid lifetimes fail closed with an error, while valid values continue
to calculate exp from now and preserve the existing signing flow.

In `@website/supabase/functions/issue-license/index.ts`:
- Around line 113-118: Update the claims construction in issue-license to stop
assigning body.license_key to sub; use an opaque, non-reusable identifier for
the subject instead, while preserving licenseId as the revocation target in jti
and leaving the tier, seats, and caps claims unchanged.
- Around line 54-65: Update the request parsing and validation in the
issue-license handler’s try block to perform runtime checks instead of relying
on the erased IssueRequest type: require a non-null, non-array object with
string license_key and machine_id_hash values that are non-empty after trimming.
Return the existing 400 missing_fields response for invalid shapes or values,
and handle malformed JSON as a client error rather than allowing it to become a
500.
- Around line 1-2: Update the Supabase client import version in
website/supabase/functions/issue-license/index.ts and
website/supabase/functions/get-revocations/index.ts from 2.39.3 to the website’s
aligned 2.98.0 version, preserving the existing import structure.

In `@website/supabase/migrations/20260718000000_revocations.sql`:
- Around line 14-19: Change the foreign-key behavior on license_key_id in the
revocation table definition so deleting a license preserves the revocation row
and its jti while clearing only the optional license relationship. Replace the
cascade-delete action with the appropriate nullifying behavior, ensuring
license_key_id remains nullable and kid-only revocations continue to work.
- Around line 59-66: Make the offline revocation insert concurrency-safe by
adding a partial unique index on public.revocations for the license-key/JTI
combination used when jti equals the license key ID, then update the insert in
the offline path to use ON CONFLICT DO NOTHING. Remove the NOT EXISTS check once
the unique constraint handles idempotency.

---

Outside diff comments:
In `@src/Helpers/ModelPersistenceGuard.cs`:
- Around line 262-291: Update EnforceCore and the ValidationPending handling in
ValidateLicenseKey so a ValidationPending license cannot bypass
requiredCapability checks. Preserve and use attested capabilities when
available; otherwise fail closed for capability-gated operations such as
model:save, while retaining only the explicitly permitted behavior for ungated
operations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6aa5cbd0-9d5e-4d81-a94c-2dd5b2b9a9ba

📥 Commits

Reviewing files that changed from the base of the PR and between d70b641 and f318177.

📒 Files selected for processing (20)
  • .github/workflows/sonarcloud.yml
  • docs/licensing/v2-deploy-runbook.md
  • docs/licensing/v2-licensing-design.md
  • src/AiDotNet.csproj
  • src/Helpers/AsymmetricLicenseVerifier.cs
  • src/Helpers/LicenseCapabilities.cs
  • src/Helpers/LicenseClaims.cs
  • src/Helpers/LicenseRevocationProvider.cs
  • src/Helpers/ModelPersistenceGuard.cs
  • tests/AiDotNet.Tests/Helpers/AsymmetricLicenseBindingTests.cs
  • tests/AiDotNet.Tests/Helpers/LicenseServerEndpointTests.cs
  • tests/AiDotNet.Tests/Helpers/LicenseTestSupport.cs
  • tests/AiDotNet.Tests/ModuleInitializer.cs
  • tools/license-issuer/aidn2_issuer.py
  • tools/license-issuer/keygen.py
  • website/supabase/functions/_shared/aidn2.ts
  • website/supabase/functions/get-revocations/index.ts
  • website/supabase/functions/issue-license/index.ts
  • website/supabase/migrations/20260718000000_revocations.sql
  • website/supabase/migrations/20260718000100_lock_revoke_license_execute.sql

Comment thread .github/workflows/sonarcloud.yml
Comment thread docs/licensing/v2-deploy-runbook.md
Comment thread docs/licensing/v2-licensing-design.md Outdated
Comment thread src/Helpers/LicenseRevocationProvider.cs
Comment thread src/Helpers/LicenseRevocationProvider.cs Outdated
Comment thread website/supabase/functions/issue-license/index.ts
Comment thread website/supabase/functions/issue-license/index.ts
Comment thread website/supabase/functions/issue-license/index.ts
Comment thread website/supabase/migrations/20260718000000_revocations.sql Outdated
Comment thread website/supabase/migrations/20260718000000_revocations.sql Outdated
ooples and others added 2 commits July 18, 2026 01:56
…verification

The public half (JWK OKP, kid prod-2026a) of the production signing keypair whose
private key lives only as the Supabase AIDOTNET_LICENSE_SIGNING_KEY_PKCS8 function
secret. Verified end-to-end: the live get-revocations CRL signature verifies against
this key, confirming it matches the server's private key. Embedded as
AiDotNet.LicensePublicKey so the SDK verifies aidn2 tokens offline. Public by nature
(verify-only) — safe to commit; the CI inject step overwrites it with the same value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…parate RSA key

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

Resource is limited - try again in 1 day (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/franklins-projects-02a0b5a0?upgradeToPro=build-rate-limit

ooples and others added 4 commits July 18, 2026 02:16
… auto-refresh

Makes an AIDN-* server key keep working offline WITH correct capabilities and while
staying revocable, closing the online→offline gap the v2 server side (issue-license,
get-revocations) enables:

- OnlineLicenseServices: after a successful ONLINE validation, a throttled BACKGROUND
  task (off the hot path, never blocks the result) fetches + installs the signed CRL and
  mints + caches a short-lived, machine-bound aidn2 token via issue-license. When the
  server is later unreachable, Validate()/ValidateAsync() fall back to that cached token —
  verified locally by AsymmetricLicenseVerifier (signature + exp + machine-lock + CRL), so
  it only ever GRANTS when genuinely valid, never weakening security. Fail-open/best-effort
  throughout; sibling function URLs derived from the validate-license URL.
- LicenseRevocationProvider: lazily loads the last-fetched CRL from disk so revocation is
  enforced even on a fully-offline start; a live refresh supersedes it (newer iat wins).
- 8 OnlineLicenseServicesTests (URL derivation, machine-bound/expired/missing token,
  end-to-end server-unreachable→offline-token fallback, CRL round-trip). 18/18 green with
  the existing aidn2 binding tests; full project builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires a dedicated CI-only Ed25519 key so CI validates a REAL signed aidn2 token E2E
instead of the synthetic ModuleInitializer license:
- Embed the ci-2026a public key alongside prod-2026a in BuildKey/LicensePublicKey.json
  (and the AIDOTNET_LICENSE_PUBLIC_KEY_JSON CI variable) — both keys, kid-routed.
- sonarcloud.yml + heavy-timeout-nightly.yml test jobs now use secrets.AIDOTNET_CI_LICENSE_KEY
  + AIDOTNET_LICENSE_SCOPE=ci. Kept in its OWN secret so the global AIDOTNET_LICENSE_KEY
  (other branches) is untouched — zero cross-branch impact. The token is scope-fenced ("ci"),
  not machine-bound (ephemeral runners), full-caps, 1y exp; its private half is stored as a
  secret for rotation. Proven locally: LicensingIntegration + aidn2 binding tests 17/17 green
  with this exact key+scope+embed. Runbook step 6 documents rotation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uer, edge fns, migration

Resolves the 14 unresolved review findings on this PR:

Client SDK (C#):
- LicenseRevocationProvider: MERGE same-second CRLs (union deny-lists, keep later exp) instead
  of discarding them; bound exp before FromUnixTimeSeconds and ENFORCE it at access time in
  Effective() so a CRL valid-at-load doesn't stay authoritative past its exp. + regression test.
- ModuleInitializer: keep the env key only when it VERIFIES offline as Active AND grants model:save
  (or legacy empty caps) — matching ModelPersistenceGuard — instead of trusting its shape. A forged/
  expired/scope-mismatched/community key now correctly falls back to the synthetic license.

Tooling (Python):
- aidn2_issuer.py: emit jti/caps/optional scope; default validity 30d (was 5y); create the private
  key with 0o600 perms.
- keygen.py: MERGE into an existing JWK bundle (preserve keys[], reject duplicate kid) so per-key
  runs produce one multi-kid bundle, not overwrites.

Server (edge functions, redeployed + smoke-tested):
- _shared/aidn2.ts: require an explicit AIDOTNET_LICENSE_KID (no defaulted kid); validate token/CRL
  lifetimes ([1,30] integer days) before signing.
- issue-license: validate the request at runtime (invalid JSON -> 400 not 500; non-blank string
  fields); use the OPAQUE license_id as sub, never the reusable license_key.

Database (migration + forward-fix applied to prod):
- revocations FK ON DELETE SET NULL (not cascade) so deleting a license keeps its deny-list entry;
  partial unique index + ON CONFLICT DO NOTHING makes revoke_license idempotency concurrency-safe.

Docs: design §6 documents the actual { kid, payload, sig } CRL envelope; §7 the merged multi-kid bundle.

Validated: license suites 52/52 (synthetic), 17/17 (ci-token kept), 11/11 binding (incl new merge test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ooples and others added 3 commits July 18, 2026 10:00
…tation (design §9/P3)

- release-please.yml: inject src/BuildKey/LicensePublicKey.json (+ LicenseRevocation.json) from the
  repo var/secret before the release build, so a key ROTATION ships in the released NuGet without a code
  change (a set var overrides the committed file; unset leaves the committed one).
- rotate-ci-license.yml (new): monthly + manual workflow that re-signs the scope:ci ci-2026a aidn2 token
  with the CI-only private key (AIDOTNET_CI_LICENSE_SIGNING_KEY_PKCS8) and updates the
  AIDOTNET_CI_LICENSE_KEY secret before expiry (needs a CI_LICENSE_ROTATION_PAT with secrets:write, since
  GITHUB_TOKEN can't). The embedded public key is unchanged, so nothing else rebuilds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ap token (design §P2)

Wires register-community-license into the aidn2 issuer (per design §7/§10/§13-P2), safely: after issuing
the AIDN-* community key it also mints a short-exp (7d) aidn2 "bootstrap" token via the shared signer and
returns it as `bootstrap_token`, so a new community user can LOAD models offline immediately (before the
SDK's first online validation derives a machine-bound token).

Safe precisely because it's community-only: the token carries community caps (`tensors:load` — NO
save/persist), so a non-machine-bound token grants nothing beyond the offline read community already
allows. Paid tiers deliberately get NO non-bound token — their SDK derives a machine-bound one via
issue-license. Minting is best-effort (never throws / never fails an otherwise-successful registration);
`sub`/`jti` are the opaque license id, never the reusable key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bootstrap, P4 status

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ooples and others added 4 commits July 18, 2026 10:57
…00 on every new activation)

Found by end-to-end testing against the live endpoints: validate-license returned 500 server_error
for any first-time activation on a new machine. Root cause: 20260610000000_log_validations_to_api_usage
rebuilt validate_license_key from the pre-fix version and reintroduced pg_advisory_xact_lock(v_license.id)
— that overload takes bigint, not uuid, so it threw `pg_advisory_xact_lock(uuid) does not exist` on the
new-activation path (existing activations short-circuit before the lock, hiding it). Live since 2026-06-10;
every new CI runner / new customer machine hit it — a direct contributor to the "invalid license in CI"
symptom this whole effort started from.

Restores the 20260504 hashtextextended(uuid::text)->bigint fix at both lock sites, keeping the api_usage
logging. Applied to prod (yfkqwpgjahoamlgckjib) via forward migration 20260718000300; also corrected the
regressing 20260610 file so fresh environments get the right function directly.

Verified E2E after the fix: validate-license (community -> tensors:load; professional -> save+load),
issue-license -> a machine-bound aidn2 token that verifies against the embedded prod-2026a key with opaque
sub=license_id, and revoke_license -> the jti appears in the signed get-revocations CRL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…icing success UI

register-community-license now returns a short-exp, load-only aidn2 `bootstrap_token`; the pricing page's
"Community license created!" panel now shows it in a collapsed, clearly-optional "Advanced" details block
(most users just use the AIDN-* key above). Rendered only when the field is present, so older/paid flows
are unaffected. Astro build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… offline token)

Points #1891's test jobs at the global AIDOTNET_LICENSE_KEY secret (now a dedicated enterprise CI key:
save-capable, ~unlimited activations under a persistent CI user) instead of the scope-fenced ci-2026a aidn2
token. One consistent, save-capable key across all branches + external pipelines — and unlike the ci token,
it validates online regardless of which public key the SDK embeds (the ci token only worked in builds that
embed ci-2026a, so it never fixed external consumers).

- sonarcloud.yml + heavy-timeout-nightly.yml: use secrets.AIDOTNET_LICENSE_KEY (no scope). ModuleInitializer
  swaps this online AIDN-* key for the deterministic synthetic OFFLINE license, so the persistence-heavy
  suite stays licensed with no network round-trip (verified: 45/45 with the enterprise key set).
- Removed rotate-ci-license.yml (rotated the now-unused ci token; it also needed a secrets:write PAT).
- Reverted src/BuildKey/LicensePublicKey.json + the AIDOTNET_LICENSE_PUBLIC_KEY_JSON var to prod-only
  (prod-2026a stays — it signs real users' offline tokens via issue-license). Deleted the unused
  AIDOTNET_CI_LICENSE_KEY / signing-key secrets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ooples added a commit that referenced this pull request Jul 18, 2026
… offline token)

Points #1891's test jobs at the global AIDOTNET_LICENSE_KEY secret (now a dedicated enterprise CI key:
save-capable, ~unlimited activations under a persistent CI user) instead of the scope-fenced ci-2026a aidn2
token. One consistent, save-capable key across all branches + external pipelines — and unlike the ci token,
it validates online regardless of which public key the SDK embeds (the ci token only worked in builds that
embed ci-2026a, so it never fixed external consumers).

- sonarcloud.yml + heavy-timeout-nightly.yml: use secrets.AIDOTNET_LICENSE_KEY (no scope). ModuleInitializer
  swaps this online AIDN-* key for the deterministic synthetic OFFLINE license, so the persistence-heavy
  suite stays licensed with no network round-trip (verified: 45/45 with the enterprise key set).
- Removed rotate-ci-license.yml (rotated the now-unused ci token; it also needed a secrets:write PAT).
- Reverted src/BuildKey/LicensePublicKey.json + the AIDOTNET_LICENSE_PUBLIC_KEY_JSON var to prod-only
  (prod-2026a stays — it signs real users' offline tokens via issue-license). Deleted the unused
  AIDOTNET_CI_LICENSE_KEY / signing-key secrets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ess (no PAT)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Commit messages auto-fixed

One or more commit messages did not follow Conventional Commits, so they were rewritten to comply (subject case, header length ≤ 100, valid type). Each commit and its diff were preserved — no squashing.

The branch was force-pushed with the corrected messages. If you have local work on this branch, run git pull --rebase (or reset to the remote) before pushing again.

@ooples
ooples force-pushed the fix/license-v2-validation branch from 06b0a23 to e3ce888 Compare July 18, 2026 15:52
@ooples
ooples merged commit c551cbb into master Jul 18, 2026
17 of 98 checks passed
@ooples
ooples deleted the fix/license-v2-validation branch July 18, 2026 16:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant