Skip to content

feat: add Polar.sh license activation, deactivation, status #130

Open
feynon wants to merge 6 commits into
mainfrom
feat/polar-licensing-v2
Open

feat: add Polar.sh license activation, deactivation, status #130
feynon wants to merge 6 commits into
mainfrom
feat/polar-licensing-v2

Conversation

@feynon

@feynon feynon commented Apr 27, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds tiles license activate|deactivate|status backed by Polar.sh's public customer-portal license-key endpoints.
  • Persists activations in a single-row polar_license table in the encrypted common DB, keyed by a v7 UUID device id.
  • Startup banner now prints the license label after the version: Tiles 0.4.8 Backer License / ... Commercial License / ... Unlicensed.

Notes

  • License type is derived from the key prefix (BACKER- / COMMERCIAL-); commercial keys carry an expiry, backer keys are lifetime.
  • Validation pulls expires_at from Polar; on transport failure during status, falls back to cached values with an "(offline)" note.
  • Activations allowed: N displays Polar's limit_activations; the public customer-portal endpoints don't expose a per-key activation count, so "used / N" is intentionally omitted.

Test plan

  • cargo build -p tiles
  • cargo test -p tiles --lib -- core::account::license core::storage::db (10 tests pass; new polar_license migration validated)
  • tiles license activate <BACKER-…> → "Activated Backer License (key ****-XXXX)"
  • tiles license status → "Backer License", Expires: Never, Activations allowed: 5
  • tiles --no-repl banner: Tiles 0.4.8 Backer License
  • tiles license activate <COMMERCIAL-…> → status shows expiry + days remaining; banner: Tiles 0.4.8 Commercial License
  • tiles license deactivate → releases activation slot via Polar
  • Re-activation while active → refuses with deactivate hint
  • Unknown-prefix key → clean error, no row written
  • Verify on a fresh install (no existing dev DB)

🤖 Generated with Claude Code

Introduce `tiles license activate|deactivate|status` backed by Polar's
public customer-portal license-key endpoints. Activations are persisted
in the encrypted common DB (single-row `polar_license` table) keyed by a
v7 UUID device id. License type is derived from the key prefix
(`BACKER-` / `COMMERCIAL-`); commercial keys carry an expiry, backer
keys are lifetime. The startup banner now prints the license label
("Tiles 0.4.8 Backer License" / "... Commercial License" /
"... Unlicensed") right after the version.

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

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@feynon has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 34 minutes and 27 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 4ead1e53-d938-4060-bd59-916b5f74ca13

📥 Commits

Reviewing files that changed from the base of the PR and between e57acb4 and 6ec66a5.

📒 Files selected for processing (2)
  • tiles/src/core/account/license.rs
  • tiles/src/core/storage/db.rs
📝 Walkthrough

Walkthrough

This PR introduces Polar.sh license management functionality into the application. A new license submodule is added to handle license activation, deactivation, and status checking via Polar customer-portal API endpoints. Licenses are stored in a new SQLite polar_license table with single-row enforcement. Additionally, the FTUE setup function is updated to accept a database connection and include the current license label in its console output. New CLI commands (license activate, deactivate, status) are wired to the license management functions.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as CLI/User
    participant Main as main.rs
    participant License as license module
    participant DB as SQLite DB
    participant Polar as Polar API

    CLI->>Main: license activate <key>
    Main->>License: activate(conn, key)
    License->>DB: Check existing license
    DB-->>License: No active license
    License->>Polar: POST /activate (key, device_id, etc.)
    Polar-->>License: Activation response
    License->>Polar: POST /validate (activation_id)
    Polar-->>License: License details
    License->>DB: Store license row
    DB-->>License: Success
    License-->>Main: Result
    Main-->>CLI: Status message
Loading
sequenceDiagram
    participant CLI as CLI/User
    participant Main as main.rs
    participant License as license module
    participant DB as SQLite DB
    participant Polar as Polar API

    CLI->>Main: license status
    Main->>License: status(conn)
    License->>DB: Fetch cached license
    DB-->>License: License data
    License->>Polar: POST /validate (activation_id)
    Polar-->>License: Current expiry/limits
    License->>DB: Update cached fields
    DB-->>License: Success
    License-->>Main: License label/status
    Main-->>CLI: Display status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Added FTUE changes for account setup #88: Directly modifies the run_setup_for_ftue function signature and output to include license information, aligning with this PR's updates to display the current license label in FTUE.
  • streamline db conn references #112: Related refactor that threads Dbconn through command functions—this PR similarly updates run_setup_for_ftue to accept &Dbconn as part of a broader pattern of passing database connections into commands.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% 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 The PR description clearly outlines the changes: adding Polar.sh license management CLI commands, persisting activations in SQLite, and displaying license labels in the startup banner.
Title check ✅ Passed The pull request title clearly and concisely summarizes the main feature: adding Polar.sh license management commands (activation, deactivation, and status).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/polar-licensing-v2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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: 4

🧹 Nitpick comments (3)
tiles/src/core/storage/db.rs (1)

55-70: Migration looks correct; flagging vestigial activations_used column.

The migration is appended at the end of the array (good — preserves ordering for existing installs) and the single-row invariant is properly enforced via CHECK (id = 1). The migrations_test will continue to validate this.

One nit: per the PR description, the public Polar customer-portal endpoints do not expose the per-key used count, and activations_used is always written as None in license.rs. The column is effectively dead storage today. That's fine if you intend to populate it later (e.g., from an authenticated/admin endpoint), but consider either dropping it from the schema or adding a code comment that documents the forward-compat intent so future readers don't think the writer is buggy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tiles/src/core/storage/db.rs` around lines 55 - 70, Migration adds a
vestigial activations_used column in the polar_license table; either remove that
column from the CREATE TABLE statement (delete the activations_used column
token) or explicitly document intent so it's not mistaken for a bug—if you keep
it, add a clear comment near the migration SQL mentioning
polar_license.activations_used is reserved for future admin/portal population
and currently always written as None in license.rs, and update any tests
(migrations_test) expectations if needed to reflect the schema change.
tiles/src/core/account/license.rs (2)

99-105: Heads-up: blocks license-key swap (BACKER → COMMERCIAL upgrades require manual deactivate first).

activate rejects with a clear error if any row exists, which is correct for guarding accidental re-activation, but it also means a user upgrading from a Backer key to a Commercial key has to run tiles license deactivate first — which talks to Polar to release the old activation. If they're offline at upgrade time, they're stuck.

Acceptable for v1 given the error message points them at the right command, just calling out the workflow gap so it doesn't surprise support. A follow-up could add tiles license activate --replace <KEY> that performs deactivate-then-activate atomically.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tiles/src/core/account/license.rs` around lines 99 - 105, The activate flow
currently rejects if any existing license row is present (see fetch_license in
tiles/src/core/account/license.rs), which blocks key swaps; add an explicit
--replace flag to the activate command and update the activate handler to, when
--replace is set and fetch_license(&conn.common) returns Some(existing), call
the existing deactivation routine (e.g., deactivate_license / the function that
releases the activation) before continuing to perform the new activation,
preserving the existing logging (mask_key, label_for_type) and ensuring errors
from the deactivation call are propagated to the user so offline failures remain
visible.

439-523: Consider replacing the custom RFC3339 parser with time or chrono.

The implementation lacks input validation that a standard library would provide:

  • No range checks on month, day, hour, minute, second — e.g., 2027-13-40T25:99:99Z would parse and produce an unpredictable result instead of rejecting it.
  • Timezone format support (±hh:mm and ±hhmm) is partially undocumented in the function comment.
  • Limited scope compared to battle-tested datetime crates.

Neither time nor chrono is currently in the dependency tree, so adding one is straightforward. The current tests cover only valid formats Polar emits, but input validation would improve robustness. Not blocking, but worth scheduling as a follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tiles/src/core/account/license.rs` around lines 439 - 523, The custom parser
parse_rfc3339_to_unix_seconds (and its helpers days_from_civil / civil_from_days
/ format_unix_date) lacks full validation and should be replaced with a
battle‑tested crate: add either the time or chrono dependency and implement
parse_rfc3339_to_unix_seconds by delegating to the crate's RFC3339 parser (e.g.,
chrono::DateTime::parse_from_rfc3339 or time::OffsetDateTime::parse) and
returning the UTC unix seconds; remove the manual parsing logic and helpers or
keep them only if still needed for formatting, and update/add tests to cover
invalid months/days/hours/minutes/seconds and the supported timezone formats.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tiles/src/core/account/license.rs`:
- Line 149: Run rustfmt by executing `cargo fmt -p tiles` to fix the formatting
CI failure; specifically reformat the println! block (the multi-argument
println!("{} {} (key {})", …) area), the polar_activate function parameter list
so it fits one line, the long byte-equality if chain in
parse_rfc3339_to_unix_seconds, and the timezone-offset if/else chain inside the
+/- branch so they reflow per rustfmt rules; locate these by searching for the
println! call, the polar_activate declaration/usage, and the
parse_rfc3339_to_unix_seconds function and then commit the formatted file.
- Around line 111-149: After successful polar_activate and polar_validate but
before returning, ensure that if upsert_license(&conn.common, &row)? fails you
call polar_deactivate(&client, key, &activation_id).await to release the remote
activation; mimic the validate-failure rollback pattern (i.e., capture the
upsert error, attempt let _ = polar_deactivate(&client, key,
&activation_id).await, then return the original upsert error wrapped as before).
Update the code around the upsert_license call (and related activation_id/row
creation) to perform this rollback on persistence failure.
- Line 199: The call to upsert_license(&conn.common, &row) currently swallows
any error (let _ = ...); change this to handle and log failures so cache-write
errors are visible: call upsert_license(&conn.common, &row) and if it returns
Err(e) (or a Result), emit a warn! or info! with context (e.g., "failed to
refresh license cache" and include e, license id or row-identifying fields) so
transient or chronic cache problems can be observed; do this in the same scope
where conn.common and row are available (the upsert_license invocation).
- Around line 208-220: The days calculation in the match arm for (_, Some(exp))
under-reports time by truncating: replace the simple floor division using
unix_now_seconds() with logic that computes rem = exp - now and then if rem <= 0
mark "EXPIRED", else if rem < 86_400 display hours remaining (e.g., round up
rem/3_600) and otherwise compute days by rounding up (e.g., (rem +
86_399)/86_400) so nearly-full days show as "1 day remaining"; update the suffix
construction and println! that uses format_unix_date(exp) accordingly inside the
(_, Some(exp)) branch that currently references unix_now_seconds(), days,
suffix, and format_unix_date.

---

Nitpick comments:
In `@tiles/src/core/account/license.rs`:
- Around line 99-105: The activate flow currently rejects if any existing
license row is present (see fetch_license in tiles/src/core/account/license.rs),
which blocks key swaps; add an explicit --replace flag to the activate command
and update the activate handler to, when --replace is set and
fetch_license(&conn.common) returns Some(existing), call the existing
deactivation routine (e.g., deactivate_license / the function that releases the
activation) before continuing to perform the new activation, preserving the
existing logging (mask_key, label_for_type) and ensuring errors from the
deactivation call are propagated to the user so offline failures remain visible.
- Around line 439-523: The custom parser parse_rfc3339_to_unix_seconds (and its
helpers days_from_civil / civil_from_days / format_unix_date) lacks full
validation and should be replaced with a battle‑tested crate: add either the
time or chrono dependency and implement parse_rfc3339_to_unix_seconds by
delegating to the crate's RFC3339 parser (e.g.,
chrono::DateTime::parse_from_rfc3339 or time::OffsetDateTime::parse) and
returning the UTC unix seconds; remove the manual parsing logic and helpers or
keep them only if still needed for formatting, and update/add tests to cover
invalid months/days/hours/minutes/seconds and the supported timezone formats.

In `@tiles/src/core/storage/db.rs`:
- Around line 55-70: Migration adds a vestigial activations_used column in the
polar_license table; either remove that column from the CREATE TABLE statement
(delete the activations_used column token) or explicitly document intent so it's
not mistaken for a bug—if you keep it, add a clear comment near the migration
SQL mentioning polar_license.activations_used is reserved for future
admin/portal population and currently always written as None in license.rs, and
update any tests (migrations_test) expectations if needed to reflect the schema
change.
🪄 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.yml

Review profile: CHILL

Plan: Pro

Run ID: 51487cad-256e-42c1-b8df-f47066d0a4c4

📥 Commits

Reviewing files that changed from the base of the PR and between f1cfbe1 and e57acb4.

📒 Files selected for processing (5)
  • tiles/src/commands/mod.rs
  • tiles/src/core/account/license.rs
  • tiles/src/core/account/mod.rs
  • tiles/src/core/storage/db.rs
  • tiles/src/main.rs

Comment thread tiles/src/core/account/license.rs Outdated
Comment thread tiles/src/core/account/license.rs Outdated
Comment thread tiles/src/core/account/license.rs Outdated
Comment thread tiles/src/core/account/license.rs
@feynon feynon changed the title feat: add Polar.sh license activation, deactivation, status (alt) feat: add Polar.sh license activation, deactivation, status Apr 27, 2026
feynon and others added 5 commits April 28, 2026 04:27
Mirrors the existing validate-failure rollback so a DB write error
no longer leaves an orphaned remote activation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The status path silently dropped upsert errors, hiding chronic cache
write problems. Surface them via warn! with the masked key and
activation id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Floor-dividing seconds-to-expiry by 86400 made nearly-full days
display as "0 days remaining". Use ceiling division and fall
back to hours when under a day.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Polar's customer-portal API does not expose per-key activation counts,
so the column was always written as NULL. Remove it from the schema,
StoredLicense struct, upsert/fetch SQL, and tests.

Migration is edited in place since this branch hasn't shipped yet
and the dev DB has been wiped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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