Skip to content

Bind both halves of the caller on the content ticket path - #41

Merged
LucaCappelletti94 merged 7 commits into
mainfrom
fix/content-ticket-caller-binding
Sep 20, 2026
Merged

LucaCappelletti94 merged 7 commits into
mainfrom
fix/content-ticket-caller-binding

Conversation

@LucaCappelletti94

@LucaCappelletti94 LucaCappelletti94 commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Chapter 08 promises four arrival cases for a caller, and says every row is load-bearing: an identity, a share key, both, or neither. Every path that runs SQL as the caller honoured that through one shared binding, except the content ticket path, which rendered the caller as the identity or an empty string. A caller whose rights come from a share key could therefore never obtain a ticket, and an unidentified one was bound to a blank identity the chapter explicitly forbids, since an empty string is a real identity a policy can match where an unbound setting reads as NULL and fails closed.

The caller now crosses the mint seam as a value with two optional halves, and the ticket carries both, so the file server binds what the mint bound. One binding function replaces the three inline bindings behind the download, the needed-hashes answer and the commit dedup check, and the file server takes the setting names from its own configuration rather than a literal, so a deployment that renamed either is answered under its own names. A caller holding neither half owns no manifest key and is refused when it declares an upload, rather than keyed on the empty string alongside every other nameless caller, and that same key is what the deployment receives as the commit's attribution. The per-viewer re-execution read follows the same rule: a share key is somebody to read as, so only a caller that binds nothing is refused.

Every case was watched failing first, for the reason stated, and the ones that could go green by accident were re-confirmed by removing the subject half and the configured name again afterwards. Two things are deliberately not here. The demo's browser arm needs a policy shape that admits a share key, which no translator pair accepts today, and a caller holding only a key still cannot take a subscription whose policy compiles a term, because the membership mirror renders the identity and has no spelling for a subject.

Summary by Sourcery

Propagate the complete caller through content ticket minting and file-server authorization so share-key access works consistently while absent caller halves fail closed.

New Features:

  • Carry both caller identity and capability subjects through content tickets so identity-only, key-only, combined, and anonymous callers are handled consistently.
  • Support caller-specific manifest attribution and deduplication for share-key callers, while refusing callers that hold neither identity nor subjects.

Bug Fixes:

  • Fix content visibility, ticket minting, needed-hash calculation, commit deduplication, and per-viewer reads to bind both caller halves instead of using only an identity or an empty string.
  • Prevent pooled database connections from treating an absent caller identity as a reusable blank identity.

Enhancements:

  • Centralize caller binding with deployment-configurable setting names and a safe absent marker.
  • Use the caller's identity, subject set, or session handle as the content bandwidth attribution key.

Documentation:

  • Update authorization and file-handling architecture documentation to describe whole-caller propagation, subject-based content access, safe anonymous handling, configurable settings, and per-caller metering.

Tests:

  • Add integration coverage for key-only ticket minting and file serving, combined identity/subject authorization, renamed settings, anonymous callers, pooled-connection reuse, key-based deduplication, and subject-aware per-viewer reads.

Chores:

  • Move shared caller setting constants into core authentication types and update server consumers.

@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Repository: LucaCappelletti94/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: a0adf2c9-e737-4ebd-bd56-1f5f85bbddee


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.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @LucaCappelletti94, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 23 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-20T16:04:16.751426Z 48d2da0 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sourcery-ai

sourcery-ai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR closes the content-ticket caller-binding gap by carrying identity and capability subjects together, binding whichever halves are present under deployment-configured settings throughout minting and file-server authorization checks, and using a consistent non-empty attribution key for uploads, commits, and metering; extensive integration tests cover all four caller states and renamed settings.

Sequence diagram for content ticket caller binding

sequenceDiagram
    participant Session as Session
    participant Signer as ContentTicketSigner
    participant Ticket as ContentTicket
    participant FileServer as FileServer
    participant DB as ReaderDatabase

    Session->>Signer: mint(ContentCaller, file_id, verb)
    Signer->>Ticket: Store identity and subjects
    Session->>FileServer: Request content with ticket
    FileServer->>DB: bind_caller(settings, ticket.caller)
    DB-->>FileServer: Bind present halves, leave absent halves unbound
    FileServer->>DB: connetto_visible_files(file_id)
    DB-->>FileServer: Visibility result
Loading

Flow diagram for caller attribution and upload ownership

flowchart TD
    A[Ticket carries ContentCaller] --> B{Caller has identity?}
    B -->|Yes| C[Use identity as manifest key]
    B -->|No| D{Caller has subjects?}
    D -->|Yes| E[Use packed subjects as manifest key]
    D -->|No| F[Refuse upload: no manifest key]
    C --> G[Bind present caller halves]
    E --> G
    G --> H[Run visibility and dedup checks]
    H --> I[Commit with the same key as attribution]
Loading

File-Level Changes

Change Details Files
Represent the content-ticket caller as optional identity and packed-subject halves across minting, ticket serialization, and file serving.
  • Added ContentCaller with absent-vs-empty semantics and attribution-key selection.
  • Changed ContentTicketSigner::mint and ticket payloads to carry the full caller.
  • Propagated callers from session authorization through ticket minting and verification.
crates/connetto-core/src/auth.rs
crates/connetto-core/src/lib.rs
crates/connetto-core/src/traits.rs
crates/connetto-file-server/src/ticket.rs
crates/connetto-server/src/session.rs
crates/connetto-server/src/capability.rs
crates/connetto-server/src/bin/connetto-server.rs
crates/connetto-test-harness/src/lib.rs
Centralize file-server caller binding and make binding names deployment-configurable across all authorization-sensitive content operations.
  • Added CallerSettings and one bind_caller implementation that binds identity, subjects, both, or neither without materializing empty strings.
  • Replaced inline bindings in serving, needed-hashes, and commit dedup checks.
  • Configured the file server with its own identity and subject setting names and reused the configured identity name for per-viewer re-execution.
crates/connetto-file-server/src/caller.rs
crates/connetto-file-server/src/router.rs
crates/connetto-file-server/src/db.rs
crates/connetto-file-server/src/needed.rs
crates/connetto-file-server/src/serve.rs
crates/connetto-file-server/src/upload.rs
crates/connetto-server/src/write_target.rs
crates/connetto-server/src/reexec.rs
crates/connetto-server/src/session.rs
Use the caller attribution key consistently for manifest ownership, commit attribution, metering, and anonymous refusal.
  • Select identity first, then packed subjects, as the manifest and commit key.
  • Reject callers with neither half before creating an upload manifest.
  • Key content byte metering by attribution, falling back to the session handle for unnamed callers.
  • Sort packed capability subjects for deterministic attribution.
crates/connetto-core/src/auth.rs
crates/connetto-server/src/capability.rs
crates/connetto-server/src/session.rs
crates/connetto-file-server/src/caller.rs
crates/connetto-file-server/src/upload.rs
Extend documentation and integration coverage for all caller combinations and configured setting names.
  • Added tests for key-only ticket minting and serving, identity-plus-key union behavior, unnamed callers, renamed settings, key-only dedup commits, and key-only re-execution reads.
  • Updated fixtures and existing ticket constructions to use ContentCaller.
  • Recorded the content-path and per-caller metering architecture amendments.
crates/connetto-file-server/tests/it/fixture.rs
crates/connetto-file-server/tests/it/serve.rs
crates/connetto-file-server/tests/it/upload.rs
crates/connetto-file-server/tests/it/ticket.rs
crates/connetto-server/tests/it/ticket_mint.rs
crates/connetto-server/tests/it/ticket_shared.rs
crates/connetto-server/tests/it/rls_computed.rs
docs/architecture/08-authorization.md
docs/architecture/18-file-handling.md
crates/connetto-file-client/tests/it/offline_photo.rs
crates/connetto-file-server/tests/it/preflight.rs
crates/connetto-file-server/tests/it/sweep.rs
crates/connetto-server/src/auth.rs
crates/connetto-server/src/materializer.rs
crates/connetto-server/src/openfga.rs
crates/connetto-server/src/reach.rs
crates/connetto-server/src/snapshot.rs
crates/connetto-test-harness/src/fanout.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9aeb328b15

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +78 to +79
let mut rendered: Vec<String> = keys.iter().map(|key| key.key().to_string()).collect();
rendered.sort_unstable();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Deduplicate capability subjects before deriving caller keys

When a client presents the same valid capability grant multiple times, Principal::accept retains every copy and this code only sorts them, so the same logical caller can become key:k1, key:k1,key:k1, and so on. Because the packed value now keys both manifest ownership and allow_content_bytes, a capability holder can bypass the per-caller upload window by varying the duplicate count, while equivalent later handshakes may also fail to resume the same manifest; deduplicate the rendered subjects after sorting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Right, and it bit harder than the sort suggested: the packed value keys the manifest and the upload window, so a repeated grant moved both. Packing now sorts and drops repeats, so one holder packs one value however many copies of a grant it presented.

Comment thread crates/connetto-core/src/auth.rs Outdated
Comment on lines +222 to +223
pub fn attribution(&self) -> Option<&str> {
self.identity().or_else(|| self.subjects())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Namespace identity and capability attribution keys

For deployments whose string user IDs can overlap capability renderings, an identity such as key:k1 and a capability-only caller holding key:k1 produce the same attribution string. The file server consequently treats these distinct callers as the same uploader for manifest lookup, chunk PUT, and commit, allowing one to resume or commit the other's in-progress manifest when both can obtain a write ticket for that file; encode the caller kind into the storage key rather than returning the raw value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Taken, with the two roles split rather than one value namespaced. A manifest is now owned under a key that names the half it came from, so an identity and a subject that render alike can never share a row, while the deployment keeps receiving the plain value for attribution, because that is what its own policies compare against. A test pins that an identity spelled like the key owns nothing the key holder declared.

Comment on lines +44 to +45
match (caller.identity(), caller.subjects()) {
(None, None) => Ok(()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve absence on reused PostgreSQL connections

After a custom GUC has been set with transaction-local set_config, PostgreSQL keeps the placeholder on that session and restores it to the empty string when the transaction ends; it is no longer missing. Therefore, when a pooled connection previously served an identified caller, this no-op branch makes a later caller with neither half observe current_setting(..., true) = '' rather than NULL, so a policy that legitimately matches an empty identity can authorize the anonymous caller—the exact case this change intends to prevent. Absence needs an explicit representation that survives connection reuse instead of relying on the setting never having existed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correct, and it reached further than this change: the same reliance on absence sits behind the snapshot, the write, the visibility check and the per-viewer read on main today. Measured on postgres 16, once a custom setting has a placeholder on a session it reads as the empty string for the rest of that session, and neither set_config with NULL, nor RESET, nor DISCARD ALL, nor a rolled back transaction takes it away. An unheld half now binds an unguessable per-process marker, so absence means the same thing on a fresh and a reused connection and no row can carry the value. The regression test opens a connection as an identified caller first and then asks as a caller holding nothing, and it fails without the marker.

@codecov

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.88256% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.64%. Comparing base (96017fb) to head (5a51fd2).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
crates/connetto-file-server/src/upload.rs 76.47% 0 Missing and 8 partials ⚠️
crates/connetto-file-server/src/db.rs 89.28% 0 Missing and 3 partials ⚠️
crates/connetto-test-harness/src/lib.rs 0.00% 3 Missing ⚠️
crates/connetto-file-server/src/caller.rs 97.05% 1 Missing ⚠️
crates/connetto-file-server/src/needed.rs 80.00% 0 Missing and 1 partial ⚠️
crates/connetto-file-server/src/serve.rs 85.71% 0 Missing and 1 partial ⚠️
crates/connetto-server/src/session.rs 91.66% 1 Missing ⚠️
crates/connetto-server/src/snapshot.rs 50.00% 1 Missing ⚠️
crates/connetto-server/src/write_target.rs 87.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #41      +/-   ##
==========================================
+ Coverage   83.53%   83.64%   +0.11%     
==========================================
  Files         114      115       +1     
  Lines       26322    26451     +129     
  Branches    26322    26451     +129     
==========================================
+ Hits        21987    22124     +137     
+ Misses       3096     3088       -8     
  Partials     1239     1239              
Flag Coverage Δ
client 64.16% <75.39%> (+0.13%) ⬆️
rest 56.23% <86.01%> (+0.06%) ⬆️
server 49.68% <60.35%> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@LucaCappelletti94

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 584b979595

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +162 to +163
let user = user.unwrap_or_else(|| absent_marker().to_owned());
let subjects = subjects.unwrap_or_else(|| absent_marker().to_owned());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve SQL NULL for absent caller halves

When a deployment uses current_setting(..., true) IS NOT NULL to recognize an authenticated/capability-bearing caller, these sentinel values make an anonymous caller satisfy that predicate; conversely, policies using IS NULL for anonymous access now deny it. This changes the documented absent-half semantics even on fresh connections, and the file server's bind_caller mirrors the same behavior. Use an absence representation that policies normalize back to SQL NULL, or otherwise keep absent callers on connections where the setting remains unset.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The consequence is real and deliberate, and the alternative offered is the one shape that cannot work. Keeping absent callers on connections where the setting was never set is not available under pooling: once any transaction on a session binds a custom setting the placeholder outlives it, and current_setting then returns the empty string forever, which neither set_config with NULL nor RESET nor DISCARD ALL nor a rolled back transaction undoes. That was measured on postgres 16 before the marker was chosen. Normalizing back to NULL in the policy means every deployment rewrites every policy to say NULLIF, and the failure mode of forgetting is silent. So absence is a value no row can carry rather than NULL, and a policy that wants to recognize an anonymous caller tests its own rows rather than testing the setting. Chapter 08 now says this where it used to promise NULL, and chapter 12 follows it.

Comment thread crates/connetto-core/src/auth.rs Outdated
Comment on lines +246 to +247
pub fn attribution(&self) -> Option<&str> {
self.identity().or_else(|| self.subjects())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not attribute a packed subject set as one subject

For a capability-only caller holding two grants, subjects() is a packed value such as key:k1,key:k2, so this returns the entire list and post_commit passes it to connetto_set_content_state as one uploader. A deployment setter that stores p_caller as uploaded_by—as the repository fixture does—then creates an owner that cannot match the documented policy uploaded_by = ANY(string_to_array(current_setting('app.subjects', true), ',')), because neither held subject equals the packed list; the newly attributed committed file is therefore invisible to the same caller. Attribution needs to preserve individual subjects or select an explicit subject rather than treating the packed set as one principal.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This one is right and the fix is in 48d2da0. The caller now carries its subjects as the list they are and joins them only at the binding, under the separator the deployment's key type chose, so nothing downstream can mistake the joined value for a name. The commit calls the setter once per subject, which gives a two key caller one owner row per key and lets it see its own upload under either. A deployment setter is consequently called more than once for one commit and must be idempotent, which chapter 12 now states. The regression test uploads and commits as a caller holding two keys and then reads the file back as each key alone; attributing only the first key leaves the second at 404.

/// Caller identity carried for attribution.
pub caller: String,
/// The caller this ticket binds to, both halves of it.
pub caller: ContentCaller,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Decode tickets minted with the previous payload

When a deployment reuses CONNETTO_CONTENT_KEY during a rolling upgrade, an unexpired ticket minted by the previous binary still contains a postcard String in this field, while the new verifier expects the two-option ContentCaller structure and returns TicketError::Postcard. Thus downloads and in-progress uploads intermittently fail depending on which old/new node handles the request despite sharing the signing key; version the payload or retain legacy decoding for at least one ticket TTL.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not taken. The ticket payload is internal to one deployment's own mint and verify pair, the workspace is at 0.0.0 with no released version to be compatible with, and a ticket lives minutes rather than across an upgrade window. Versioning it now would add a decoding path that no deployment can exercise and that nothing would ever remove. A rolling upgrade that must not drop in-flight tickets is a real concern for the first released version, and the place to solve it is a version byte introduced deliberately at that point, not a legacy arm carried from a pre-release shape.

@LucaCappelletti94

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48d2da0043

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +288 to +289
self.packed_subjects(',')
.map(|subjects| format!("keys:{subjects}"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Encode capability sets with their configured separator

When a deployment chooses a non-comma CapabilityKey::SEPARATOR, commas remain valid inside rendered keys because minting rejects only the configured separator. Consequently, distinct subject sets such as {"a", "b,c"} and {"a,b", "c"} both produce keys:a,b,c here. Since this value keys both manifests and the upload-byte window, those capability callers can share or interfere with the same upload state and quota when both are authorized for a file; use the deployment separator or another collision-free encoding for the storage key.

Useful? React with 👍 / 👎.

…ller-binding

# Conflicts:
#	crates/connetto-core/src/auth.rs
#	crates/connetto-server/src/bin/connetto-server.rs
#	crates/connetto-server/src/capability.rs
#	crates/connetto-test-harness/src/fanout.rs
…ller-binding

# Conflicts:
#	crates/connetto-server/src/bin/connetto-server.rs
#	crates/connetto-server/src/openfga.rs
@sonarqubecloud

Copy link
Copy Markdown

@LucaCappelletti94
LucaCappelletti94 merged commit 0dbd8d0 into main Sep 20, 2026
57 checks passed
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