<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Akanksha Trehun</title>
    <description>The latest articles on DEV Community by Akanksha Trehun (@magic-peach).</description>
    <link>https://dev.to/magic-peach</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3909309%2F540870bc-2796-4ade-a0dd-68e2fce4e1c2.png</url>
      <title>DEV Community: Akanksha Trehun</title>
      <link>https://dev.to/magic-peach</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/magic-peach"/>
    <language>en</language>
    <item>
      <title>Week 14: The Approved Pull Request That Stopped Passing</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:38:43 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-14-the-approved-pull-request-that-stopped-passing-eln</link>
      <guid>https://dev.to/magic-peach/week-14-the-approved-pull-request-that-stopped-passing-eln</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7746" rel="noopener noreferrer"&gt;#7746&lt;/a&gt;, the OIDC login&lt;br&gt;
initiation, has been approved and waiting since week 10. This week I went back&lt;br&gt;
to actually merge it and found its test suite failing on an error that had&lt;br&gt;
nothing to do with anything I remembered writing.&lt;/p&gt;
&lt;h2&gt;
  
  
  A NameError in code I had already shipped
&lt;/h2&gt;

&lt;p&gt;CI was red across the whole &lt;code&gt;oidc_login&lt;/code&gt; context, ten examples, all the same&lt;br&gt;
failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NameError:
  uninitialized constant LtiController::LTI_STATE_PURPOSE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That should have been impossible. &lt;code&gt;oidc_login&lt;/code&gt; and &lt;code&gt;lti_state_verifier&lt;/code&gt; both&lt;br&gt;
reference &lt;code&gt;LTI_STATE_PURPOSE&lt;/code&gt;, and the request spec references&lt;br&gt;
&lt;code&gt;LtiController::LTI_STATE_PURPOSE&lt;/code&gt; directly to verify the state it gets back.&lt;br&gt;
If that constant had never existed, this whole context should have failed the&lt;br&gt;
day it was written, back in week 10. It did not. Something had removed a&lt;br&gt;
constant that used to be there.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tracing it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git log -p&lt;/code&gt; on the controller file gave a clean timeline. The constants first&lt;br&gt;
appear on 5 August, in the commit that adds &lt;code&gt;oidc_login&lt;/code&gt; itself, with a comment&lt;br&gt;
explaining why the state is signed rather than kept in the session. They survive&lt;br&gt;
the review fixes from week 10. They even survive a rough merge from master on 9&lt;br&gt;
August that briefly left the file unparseable, a splice that dropped the closing&lt;br&gt;
&lt;code&gt;end&lt;/code&gt; of &lt;code&gt;oidc_login&lt;/code&gt; and spliced two of master's filter lines into the method&lt;br&gt;
body. The very next commit repairs that, and its diff still shows the constants&lt;br&gt;
sitting untouched at the top of the class, comment and all.&lt;/p&gt;

&lt;p&gt;The actual disappearance happened somewhere I was not looking. On 21 August, a&lt;br&gt;
different contributor merged a repository wide change to master, adding&lt;br&gt;
&lt;code&gt;after_action :verify_authorized&lt;/code&gt; safety nets across dozens of controllers. On&lt;br&gt;
this controller, on master's copy of the file, that meant inserting&lt;br&gt;
&lt;code&gt;skip_after_action :verify_authorized&lt;/code&gt; as the very first line inside the class&lt;br&gt;
body. Master's copy at that point had no &lt;code&gt;LTI_STATE_PURPOSE&lt;/code&gt; constants, because&lt;br&gt;
none of this branch's work had merged into master yet.&lt;/p&gt;

&lt;p&gt;On 5 September I merged master into my branch again, to catch up before opening&lt;br&gt;
the merge. Both sides had inserted a new line in exactly the same spot, right&lt;br&gt;
after &lt;code&gt;class LtiController &amp;lt; ApplicationController&lt;/code&gt;: master's&lt;br&gt;
&lt;code&gt;skip_after_action&lt;/code&gt;, and my branch's constants with their comment. Diffing the&lt;br&gt;
file immediately before and after that merge shows exactly what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; class LtiController &amp;lt; ApplicationController
&lt;span class="gd"&gt;-  # The state is signed rather than kept in the session: the launch returns as a
-  # cross-site POST that a SameSite cookie would not survive, and the signature
-  # is what proves the launch answers an initiation we made.
-  LTI_STATE_PURPOSE = "lti.launch.state"
-  LTI_STATE_TTL = 5.minutes
&lt;/span&gt;&lt;span class="gi"&gt;+  skip_after_action :verify_authorized
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No conflict markers survive in the final commit, which is the part that makes&lt;br&gt;
this easy to miss. Two branches touching the same handful of lines usually forces&lt;br&gt;
a manual conflict resolution, and somewhere in resolving that one I kept&lt;br&gt;
master's line and let mine go, without noticing the branch had put something in&lt;br&gt;
that exact spot too. A merge that finishes clean does not look like something&lt;br&gt;
that needs a second look, which is exactly why this one went unnoticed for two&lt;br&gt;
weeks.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Both constants, reinstated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;LTI_STATE_PURPOSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lti_oidc_state"&lt;/span&gt;
&lt;span class="no"&gt;LTI_STATE_TTL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The purpose string itself does not need to match the original literal, nothing&lt;br&gt;
outside this file ever reads it, only the two internal call sites and the spec&lt;br&gt;
need to agree on the same constant, which they now do by definition. The TTL&lt;br&gt;
does need to stay at five minutes, since the spec travels six minutes forward&lt;br&gt;
and checks the state is gone by then. I confirmed nothing else in the codebase&lt;br&gt;
referenced either constant, ran the project's RuboCop config against the change&lt;br&gt;
to be sure the addition was clean, and pushed. CI went from ten failures back to&lt;br&gt;
green across every job, not just the one that had been failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this says about "approved and waiting"
&lt;/h2&gt;

&lt;p&gt;An approved pull request sitting unmerged is not a pull request that is done.&lt;br&gt;
It is a pull request whose last green check run is quietly getting further from&lt;br&gt;
the truth every time master moves underneath it, and nothing about the GitHub UI&lt;br&gt;
makes that visible. The checkmark from three weeks ago looks exactly like a&lt;br&gt;
checkmark from this morning.&lt;/p&gt;

&lt;p&gt;This is the same lesson as week 10's development database drift, just at a&lt;br&gt;
different layer. State that nobody actively re-verifies accumulates damage that&lt;br&gt;
stays invisible until something finally runs against it again. There, it was a&lt;br&gt;
migration nobody had rolled back. Here, it was a merge nobody had rerun. Both&lt;br&gt;
times the fix was cheap once found and the finding was the entire cost.&lt;/p&gt;

&lt;p&gt;The practical rule I am taking from this: before merging anything that has sat&lt;br&gt;
approved for more than a couple of weeks, rebase it against current master and&lt;br&gt;
rerun the suite first, rather than trusting a check that ran against a branch&lt;br&gt;
state that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#7746&lt;/code&gt; is rebased, fixed, and green again, waiting on nothing but the merge&lt;br&gt;
button. &lt;code&gt;#7764&lt;/code&gt;, the access token exchange, is still open from last week. The&lt;br&gt;
testbench model and runner from two weeks ago are both under review. The test&lt;br&gt;
case editor is still finished and waiting locally.&lt;/p&gt;

&lt;p&gt;Next week: actually watching the login initiation merge, and picking the editor&lt;br&gt;
back up now that it has an access token to eventually authenticate against.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>opensource</category>
      <category>lti</category>
      <category>git</category>
    </item>
    <item>
      <title>Week 13: A Kid Only the Platform Can Read</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:29:36 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-13-a-kid-only-the-platform-can-read-51hj</link>
      <guid>https://dev.to/magic-peach/week-13-a-kid-only-the-platform-can-read-51hj</guid>
      <description>&lt;p&gt;The access token work that jumped the queue last week went up as&lt;br&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7764" rel="noopener noreferrer"&gt;#7764&lt;/a&gt;. It took a&lt;br&gt;
security minded review to catch a bug that would have made every single token&lt;br&gt;
request fail, and a second reviewer's confusion, arriving weeks later, to remind&lt;br&gt;
me that a review thread can point at code that no longer exists.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the tool needs before it can call anything
&lt;/h2&gt;

&lt;p&gt;Every LTI Advantage service call, posting a grade, reading a roster, is&lt;br&gt;
authenticated the same way. The tool signs a JWT assertion with its own private&lt;br&gt;
key, POSTs it to the platform's token endpoint under the OAuth 2.0 client&lt;br&gt;
credentials grant, and gets back a bearer token scoped to whatever it asked for.&lt;br&gt;
None of that is CircuitVerse specific. It is 1EdTech's Security Framework&lt;br&gt;
section 5.4.1 built on top of RFC 7523, the JWT profile for client assertions,&lt;br&gt;
which itself sits on RFC 6749's client credentials grant.&lt;/p&gt;

&lt;p&gt;Tokens are cached per deployment and per scope set, refreshed thirty seconds&lt;br&gt;
before they expire rather than exactly at expiry, so a request landing in that&lt;br&gt;
window does not race a token that is technically still valid when read and&lt;br&gt;
expired by the time it reaches the platform. A blank or non string&lt;br&gt;
&lt;code&gt;access_token&lt;/code&gt; in the response is refused instead of cached, and a response with&lt;br&gt;
no &lt;code&gt;expires_in&lt;/code&gt; at all gets no token stored, rather than a guessed default&lt;br&gt;
lifetime. Both of those came from reading the spec's failure modes rather than&lt;br&gt;
just its happy path.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug a reviewer caught before merge
&lt;/h2&gt;

&lt;p&gt;The first version signed the assertion like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;JWT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;iss: &lt;/span&gt;&lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;sub: &lt;/span&gt;&lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"RS256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;kid: &lt;/span&gt;&lt;span class="no"&gt;JWT&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JWK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;kid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reviewer flagged the &lt;code&gt;kid&lt;/code&gt; within a day. &lt;code&gt;Lti::KeyManager&lt;/code&gt;, the class that&lt;br&gt;
publishes CircuitVerse's public key at &lt;code&gt;/lti/jwks&lt;/code&gt;, derives its key id from a&lt;br&gt;
&lt;code&gt;JSON::JWK&lt;/code&gt; thumbprint. &lt;code&gt;JWT::JWK.new(signing_key).kid&lt;/code&gt; computes a &lt;code&gt;kid&lt;/code&gt; using a&lt;br&gt;
different digest entirely. The two never agree, which means the assertion would&lt;br&gt;
have named a key that does not match anything the platform can find at our own&lt;br&gt;
published JWKS endpoint. Every token request would fail, not occasionally, every&lt;br&gt;
time, because the mismatch is structural rather than a bug that only shows up&lt;br&gt;
under some inputs.&lt;/p&gt;

&lt;p&gt;The fix reuses the exact computation &lt;code&gt;KeyManager&lt;/code&gt; already does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;kid_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JWK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;thumbprint&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same input, the public key, same digest, &lt;code&gt;JSON::JWK&lt;/code&gt;'s thumbprint. I checked&lt;br&gt;
this by rereading &lt;code&gt;KeyManager#public_jwk&lt;/code&gt; line by line rather than trusting that&lt;br&gt;
two thumbprint calls with similar names must produce the same output, and they&lt;br&gt;
do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;public_jwk&lt;/span&gt;
  &lt;span class="n"&gt;jwk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JWK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:kid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;thumbprint&lt;/span&gt;
  &lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;use: &lt;/span&gt;&lt;span class="s2"&gt;"sig"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;alg: &lt;/span&gt;&lt;span class="s2"&gt;"RS256"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A comment that arrived three weeks late, pointed at code that no longer existed
&lt;/h2&gt;

&lt;p&gt;The fix shipped within a day of the review comment. Three weeks later, a second&lt;br&gt;
reviewer reread the thread from the top and asked why I was "still" using&lt;br&gt;
&lt;code&gt;JWT::JWK.new(signing_key)&lt;/code&gt;, pointing straight at the original code block.&lt;/p&gt;

&lt;p&gt;GitHub pins a review comment to the diff position it was written against. Once&lt;br&gt;
that hunk changes, the thread gets marked "Outdated," but the stale code stays&lt;br&gt;
visible inline right where the comment is anchored, and the label is easy to&lt;br&gt;
miss if you are reading a long thread quickly rather than checking the Files&lt;br&gt;
Changed tab. The question was reasonable. The premise, that the flagged line&lt;br&gt;
was still in the branch, was not.&lt;/p&gt;

&lt;p&gt;The reply pointed at the fix commit by hash, quoted the current &lt;code&gt;kid_for&lt;/code&gt;&lt;br&gt;
method, and reread &lt;code&gt;KeyManager&lt;/code&gt;'s source alongside it to show the two&lt;br&gt;
computations are the same, rather than just asserting "it's fixed, trust me."&lt;br&gt;
That habit, rederiving a claim instead of restating it, is the same one from&lt;br&gt;
week 9's JWT validator work: a test that passes for the wrong reason is worse&lt;br&gt;
than no test, and an explanation that only restates the conclusion is worse than&lt;br&gt;
one that shows the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  A question with a better answer than "I just know"
&lt;/h2&gt;

&lt;p&gt;The same reviewer asked, on a different thread, how I knew which fields&lt;br&gt;
belonged in the token request body at all, and whether it was written down&lt;br&gt;
anywhere or just something I had picked up.&lt;/p&gt;

&lt;p&gt;It is written down, in three places that stack on each other. &lt;code&gt;grant_type&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;scope&lt;/code&gt; come from RFC 6749 section 4.4, the client credentials grant itself.&lt;br&gt;
&lt;code&gt;client_assertion&lt;/code&gt; and &lt;code&gt;client_assertion_type&lt;/code&gt; come from RFC 7523 section 2.2,&lt;br&gt;
the general mechanism for authenticating with a signed JWT instead of a shared&lt;br&gt;
secret. The specific URI value platforms expect for&lt;br&gt;
&lt;code&gt;client_assertion_type&lt;/code&gt;, and the requirement that the JWT's &lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;sub&lt;/code&gt; both&lt;br&gt;
equal the tool's own client id, come from the IMS Security Framework, which is&lt;br&gt;
the layer that actually ties LTI Advantage to that OAuth mechanism.&lt;/p&gt;

&lt;p&gt;I answered with the three documents and the section numbers rather than a&lt;br&gt;
summary of what they say, because a summary is something to remember and a&lt;br&gt;
citation is something to reopen. Anyone auditing this code later, including me&lt;br&gt;
in six months, needs the second thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;The access token pull request is open with both review threads addressed, the&lt;br&gt;
kid bug fixed and explained, and the spec provenance question answered with&lt;br&gt;
citations rather than recollection. Roster sync and grade passback can now, once&lt;br&gt;
this merges, actually authenticate the calls they have been assuming all along.&lt;/p&gt;

&lt;p&gt;Next week: going back to merge an older, already approved pull request, and&lt;br&gt;
finding out it had stopped passing while nobody was looking at it.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>opensource</category>
      <category>oauth</category>
      <category>jwt</category>
    </item>
    <item>
      <title>Week 12: Two Small Pull Requests</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:26:16 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-12-two-small-pull-requests-1jgg</link>
      <guid>https://dev.to/magic-peach/week-12-two-small-pull-requests-1jgg</guid>
      <description>&lt;p&gt;Both pieces of the autograding groundwork from last week's "pipeline nobody could&lt;br&gt;
feed" post finally went up for review. Getting them out the door taught me more&lt;br&gt;
about the packaging rule I set for myself than the underlying feature did, and&lt;br&gt;
along the way I found a gap in my own merged code that rhymes uncomfortably with&lt;br&gt;
the one I found last week.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule I made myself follow
&lt;/h2&gt;

&lt;p&gt;Somewhere back in this phase I decided to open small pull requests one at a time&lt;br&gt;
and hold dependent branches locally until the parent merges, instead of stacking&lt;br&gt;
them or folding a dependency into one oversized diff. I have rejected my own&lt;br&gt;
compare pages twice for showing more than one commit, which is a strange thing to&lt;br&gt;
do to yourself but a useful one.&lt;/p&gt;

&lt;p&gt;The test case editor is the clearest reason why. Its branch holds a cherry pick&lt;br&gt;
of the testbench model's commit plus the editor's own commit, because the editor&lt;br&gt;
needs the model's table to exist to run its tests at all. Opening it now would&lt;br&gt;
show two commits on the compare page, and a reviewer would have to work out which&lt;br&gt;
one is actually new. So it stays local until the model merges, at which point I&lt;br&gt;
rebase, the borrowed commit drops out, and what is left is one clean diff over&lt;br&gt;
the editor's own files.&lt;/p&gt;
&lt;h2&gt;
  
  
  Which one first, and why it wasn't obvious
&lt;/h2&gt;

&lt;p&gt;That left a smaller decision: open the testbench model or the runner first.&lt;/p&gt;

&lt;p&gt;The runner's spec is deliberately built so it does not need the model to exist.&lt;br&gt;
It signs its double with &lt;code&gt;Struct.new(:data)&lt;/code&gt; instead of pulling in the real&lt;br&gt;
&lt;code&gt;Testbench&lt;/code&gt; factory, so the runner is green against master on its own. Nothing&lt;br&gt;
about it forces the model to land first.&lt;/p&gt;

&lt;p&gt;I opened the model first anyway, for three reasons that all point the same way.&lt;br&gt;
The test case editor is stacked on the model, not the runner, so unblocking the&lt;br&gt;
editor means merging the model first regardless. The model is also the smaller,&lt;br&gt;
easier diff: one migration, one class, one spec, no external HTTP contract to&lt;br&gt;
explain to a reviewer. And both branches touch &lt;code&gt;db/schema.rb&lt;/code&gt;, which is exactly&lt;br&gt;
the kind of file you do not want two open pull requests editing at once, since&lt;br&gt;
whichever merges second has to rebase past a version bump it did not cause.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually lives in the Testbench model
&lt;/h2&gt;

&lt;p&gt;One row per assignment, JSON in a &lt;code&gt;jsonb&lt;/code&gt; column, validated in three layers that&lt;br&gt;
mirror the shape of the data itself: suite, then group, then signal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;data_is_runnable&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"must be a testbench object"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"type must be comb or seq"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;TYPES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"must define a group"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;

  &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;validate_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each group needs a case count and at least one input and one output. Each signal&lt;br&gt;
needs a label to bind to a circuit pin, a positive bit width, and exactly as many&lt;br&gt;
values as its group's case count. That last check is why validation lives in&lt;br&gt;
Ruby instead of a database constraint. A &lt;code&gt;jsonb&lt;/code&gt; column schema check can enforce&lt;br&gt;
shape, but it cannot easily say "this array's length must equal that other&lt;br&gt;
field's value," and that cross-field relationship is most of what makes a&lt;br&gt;
testbench actually runnable rather than merely well formed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;assignment_id&lt;/code&gt; is unique at both levels, a database index and a model&lt;br&gt;
validation, because a race on create could otherwise slip a second row past a&lt;br&gt;
Ruby only check. &lt;code&gt;TYPES&lt;/code&gt; is closed to &lt;code&gt;comb&lt;/code&gt; and &lt;code&gt;seq&lt;/code&gt;, the two suite types the&lt;br&gt;
simulator can actually execute, so a suite claiming &lt;code&gt;verilog&lt;/code&gt; fails at save time&lt;br&gt;
instead of failing later, mid grade, in front of a student.&lt;/p&gt;
&lt;h2&gt;
  
  
  The runner, and the decision that already happened
&lt;/h2&gt;

&lt;p&gt;Most of the runner's design was decided last week, before a line of it existed.&lt;br&gt;
This week was writing it down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
  &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One entrypoint. It posts the project's circuit JSON and the testbench's suite&lt;br&gt;
JSON to a sidecar service over HTTP, with timeouts split into connect, write,&lt;br&gt;
and read rather than one blanket number, because a hung connection should fail&lt;br&gt;
fast while an actual simulation run legitimately needs more time than the&lt;br&gt;
handshake. Every way this can go wrong, a bad status, an unreachable host, a&lt;br&gt;
body that is not JSON, a response with no groups, collapses into one&lt;br&gt;
&lt;code&gt;Autograder::Runner::RunnerError&lt;/code&gt;, so the grading job that calls this later only&lt;br&gt;
ever needs to rescue one thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:passed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:groups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zero?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fdiv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw &lt;code&gt;groups&lt;/code&gt; array rides along inside the result even though only &lt;code&gt;score&lt;/code&gt;&lt;br&gt;
matters right now, because the results page a few pull requests from now needs&lt;br&gt;
to show which specific case failed, not just the fraction. The endpoint's host&lt;br&gt;
comes from &lt;code&gt;SIMULATOR_RUNNER_URL&lt;/code&gt;, following the same undocumented environment&lt;br&gt;
variable pattern the codebase already uses for &lt;code&gt;YOSYS_PATH&lt;/code&gt;, so anyone who has&lt;br&gt;
configured one external tool location here already knows how to configure this&lt;br&gt;
one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "next" turned out to mean
&lt;/h2&gt;

&lt;p&gt;Last week's post ended by promising the editor would land next. It has not, and&lt;br&gt;
writing the runner's pull request description is what changed my mind.&lt;/p&gt;

&lt;p&gt;I went back to reread the roster sync pull request from two weeks ago, the one&lt;br&gt;
that calls the platform's Names and Roles service, to check whether it needed&lt;br&gt;
anything from this new access token machinery. It does. So does grade passback.&lt;br&gt;
Both of those already assume an &lt;code&gt;Authorization: Bearer&lt;/code&gt; header carrying a token&lt;br&gt;
scoped to the right service, and until this week nothing in the codebase could&lt;br&gt;
produce one. I had built two pull requests that read from and write to platform&lt;br&gt;
services that require an OAuth exchange neither of them performs.&lt;/p&gt;

&lt;p&gt;This is the same shape of gap as last week's missing test case editor: a feature&lt;br&gt;
description satisfied on paper by code that quietly assumes a piece nobody has&lt;br&gt;
built yet. The difference is I caught this one myself, by rereading my own&lt;br&gt;
merged work with the new piece in mind, rather than having a mentor's evaluation&lt;br&gt;
catch it for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;The testbench model and the runner are both open for review, in that order.&lt;br&gt;
Roster sync and grade passback are merged but cannot actually authenticate&lt;br&gt;
against a platform yet. The test case editor is finished and waiting locally for&lt;br&gt;
the model to merge.&lt;/p&gt;

&lt;p&gt;Next week: the access token exchange that both of those merged pull requests&lt;br&gt;
have been silently depending on.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>lti</category>
      <category>opensource</category>
      <category>dev</category>
    </item>
    <item>
      <title>Week 11: Welcome to my Midterm evaluation</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Sun, 16 Aug 2026 06:06:06 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-11-welcome-to-my-midterm-evaluation-2l</link>
      <guid>https://dev.to/magic-peach/week-11-welcome-to-my-midterm-evaluation-2l</guid>
      <description>&lt;p&gt;Midterm evaluations ran from the 10th to the 14th. Deep linking and roster sync both&lt;br&gt;
shipped, two more pull requests merged, and then I sat down to write an honest account of&lt;br&gt;
where the project stands — which is how I found out that the plan I have been working&lt;br&gt;
from for a month builds a machine nobody can switch on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deep linking, end to end
&lt;/h2&gt;

&lt;p&gt;Two pull requests, two days apart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7772" rel="noopener noreferrer"&gt;#7772&lt;/a&gt; handles the request&lt;br&gt;
side: a launch arrives with &lt;code&gt;LtiDeepLinkingRequest&lt;/code&gt; as its message type instead of&lt;br&gt;
&lt;code&gt;LtiResourceLinkRequest&lt;/code&gt;, and everything downstream has to route differently. The&lt;br&gt;
platform sends along &lt;code&gt;deep_linking_settings&lt;/code&gt; — where to return the response, whether&lt;br&gt;
multiple items are allowed, whether the platform wants a title back — and that has to be&lt;br&gt;
stashed for the response, which happens on a completely separate request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7777" rel="noopener noreferrer"&gt;#7777&lt;/a&gt; is what the instructor&lt;br&gt;
actually sees: a picker listing their circuits and assignments, with the selection posted&lt;br&gt;
back to the LMS as a signed JWT.&lt;/p&gt;

&lt;p&gt;The satisfying part is that neither pull request needed anything new from the protocol&lt;br&gt;
layer. The JWT signing is the key manager from week 4. The validation on the way in is&lt;br&gt;
the validator from week 9. Deep linking is a different &lt;em&gt;conversation&lt;/em&gt; with the platform,&lt;br&gt;
but it is the same handshake underneath, and by this point the handshake is just there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roster
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7779" rel="noopener noreferrer"&gt;#7779&lt;/a&gt; reads a course roster&lt;br&gt;
over the Names and Roles service — pagination via &lt;code&gt;Link&lt;/code&gt; headers, roles parsed out of the&lt;br&gt;
IMS role URIs. &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7781" rel="noopener noreferrer"&gt;#7781&lt;/a&gt; turns&lt;br&gt;
that into group members.&lt;/p&gt;

&lt;p&gt;The interesting decision in the second one is how users get matched. Not on email — that&lt;br&gt;
was week 5's lesson, and the reason for it holds here too: a platform's privacy settings&lt;br&gt;
may mean no email arrives at all, and even when one does, it might belong to a different&lt;br&gt;
CircuitVerse account than the person clicking. The match is on &lt;code&gt;sub&lt;/code&gt; plus issuer, the&lt;br&gt;
same identity pair the launch signs the user in with. A roster import that mapped on&lt;br&gt;
email could silently add a stranger to a course.&lt;/p&gt;

&lt;p&gt;Members created this way get marked as LTI-synced, which matters for the drop-handling&lt;br&gt;
pull request that follows: when someone disappears from the LMS roster, only synced&lt;br&gt;
members get deactivated. Somebody who joined the CircuitVerse group directly should not&lt;br&gt;
be removed because they were never in Canvas to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two merges
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7715" rel="noopener noreferrer"&gt;#7715&lt;/a&gt;, the JWT validator,&lt;br&gt;
merged on the 11th. &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7763" rel="noopener noreferrer"&gt;#7763&lt;/a&gt;,&lt;br&gt;
the resource link model, merged on the 12th. Both had been sitting approved for a while —&lt;br&gt;
the validator since the end of July.&lt;/p&gt;

&lt;p&gt;Five merged now: the 1.1 passback fix, the deployment model, the JWKS and tool&lt;br&gt;
configuration endpoints, the validator, and the resource link. Eight more open and&lt;br&gt;
waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evaluation
&lt;/h2&gt;

&lt;p&gt;What the evaluation actually asked me to do was describe the state of the project, which&lt;br&gt;
sounds trivial and was not. Writing "here is what works" forces a different question than&lt;br&gt;
"here is what I built this week," and the two answers were not the same shape.&lt;/p&gt;

&lt;p&gt;So I went back to the three issues this project exists to close and read them line by&lt;br&gt;
line against what is in the repository. Not the proposal — the issues, as filed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pipeline nobody could feed
&lt;/h2&gt;

&lt;p&gt;The autograding phase of my plan had six pull requests: a test case model, a headless&lt;br&gt;
runner wrapper, a grading result, a job that runs on submission, a results page, and a&lt;br&gt;
push to the LMS gradebook. Student submits, autograder runs, grade lands in Canvas.&lt;/p&gt;

&lt;p&gt;Every one of those is about &lt;em&gt;executing&lt;/em&gt; test cases. Not one of them is about &lt;em&gt;creating&lt;/em&gt;&lt;br&gt;
test cases.&lt;/p&gt;

&lt;p&gt;The issue says it plainly — "a test case editor in the assignment settings UI where&lt;br&gt;
instructors can add, edit and delete test cases" — and I had somehow read that as&lt;br&gt;
satisfied by a database table. It is not. A model with no UI means the only way to define&lt;br&gt;
a test suite is the Rails console, which is not a feature an instructor can use. I had&lt;br&gt;
planned six pull requests that would ship a grading pipeline with no way to put anything&lt;br&gt;
into it.&lt;/p&gt;

&lt;p&gt;Reading further with that lens, four more gaps fell out. There was no visible/hidden flag&lt;br&gt;
on test cases, which both issues ask for. There was nothing for the autograde&lt;br&gt;
configuration — partial credit, allowed attempts, whether students see the cases before&lt;br&gt;
submitting. "Versioned with the assignment" had no plan behind it at all, and my model&lt;br&gt;
actively worked against it: one row per assignment, edited in place, so changing a suite&lt;br&gt;
would quietly invalidate every grade already produced from it.&lt;/p&gt;

&lt;p&gt;And the least visible one, which I only found by reading the &lt;code&gt;Grade&lt;/code&gt; model rather than&lt;br&gt;
remembering it: nothing in the plan mapped a score onto an assignment's grading scale.&lt;br&gt;
The autograder produces a fraction. &lt;code&gt;Grade&lt;/code&gt; validates against &lt;code&gt;letter&lt;/code&gt;, &lt;code&gt;percent&lt;/code&gt;, or&lt;br&gt;
&lt;code&gt;custom&lt;/code&gt;, and rejects &lt;code&gt;no_scale&lt;/code&gt; outright. Autograding an assignment with no grading&lt;br&gt;
scale would have failed at the last step, after the run, with the student watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty-four becomes twenty-seven
&lt;/h2&gt;

&lt;p&gt;Week 7's post was called "Twenty-Four Pull Requests." That plan is now twenty-seven, all&lt;br&gt;
three additions in the autograding phase: the test case editor, the autograde settings,&lt;br&gt;
and the grading-scale mapping — plus reworking the grading result to carry an attempt&lt;br&gt;
number and a snapshot, and folding hidden-case redaction into the results page.&lt;/p&gt;

&lt;p&gt;The snapshot is how "versioned with the assignment" gets solved, and it is the change I&lt;br&gt;
am happiest with. Rather than a version table with all the machinery that implies, each&lt;br&gt;
grading result stores a copy of the suite it ran against. Edit the suite afterwards and&lt;br&gt;
old results stay reproducible, because the thing that produced them is still sitting&lt;br&gt;
right there next to them.&lt;/p&gt;

&lt;p&gt;Three of the nine are written. The model and the runner are pushed; the editor is done&lt;br&gt;
and waiting locally, because it needs the model's table to exist and I would rather open&lt;br&gt;
one clean pull request after the first merges than one showing two commits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusing a format instead of inventing one
&lt;/h2&gt;

&lt;p&gt;The nicest thing I wrote this week is four words long.&lt;/p&gt;

&lt;p&gt;A test case needs a name, input pin values, expected output pin values, and a hidden&lt;br&gt;
flag. The simulator's testbench format already has &lt;em&gt;groups&lt;/em&gt; — a named collection of cases&lt;br&gt;
with input and output signals. My first instinct was to add fields: a &lt;code&gt;name&lt;/code&gt; array, a&lt;br&gt;
&lt;code&gt;hidden&lt;/code&gt; array, indices lining up across them.&lt;/p&gt;

&lt;p&gt;Then: a test case is just a group with &lt;code&gt;n: 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One case, one name, one hidden boolean. It validates against the model I had already&lt;br&gt;
written without changing a line of it, it invents no schema, and when the suite reaches&lt;br&gt;
the simulator it is a format the simulator already knows how to run. The editor&lt;br&gt;
serialises its table into exactly that on submit.&lt;/p&gt;

&lt;p&gt;I have written the other version of this before — the one with parallel arrays and a&lt;br&gt;
comment explaining how the indices correspond. It works right up until someone deletes a&lt;br&gt;
row from one array and not the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not reimplementing the simulator
&lt;/h2&gt;

&lt;p&gt;The runner wrapper was the piece I was most nervous about, and the design question was&lt;br&gt;
where the simulation actually happens.&lt;/p&gt;

&lt;p&gt;The tempting answer was Ruby: run the circuit server-side, in the language the rest of&lt;br&gt;
the grading code is written in. I talked myself out of it in about ten minutes. It means&lt;br&gt;
reimplementing thousands of lines of simulation logic, and then maintaining two engines&lt;br&gt;
that will drift — and the failure mode of that drift is a student's circuit passing in&lt;br&gt;
the browser and failing at grading. For a grading tool there is no worse bug.&lt;/p&gt;

&lt;p&gt;Driving a real browser was the next candidate, and &lt;code&gt;capybara-playwright-driver&lt;/code&gt; is&lt;br&gt;
already in the Gemfile, so it looked nearly free. A browser process per submission is not&lt;br&gt;
nearly free.&lt;/p&gt;

&lt;p&gt;Shelling out to Node was the closest call, because there is precedent: the Yosys&lt;br&gt;
integration in this repository does exactly that. I went with an HTTP call to a sidecar&lt;br&gt;
service instead, mirroring the other half of that same integration — a URL from the&lt;br&gt;
environment, the same timeout-and-post shape the simulator controller already uses. It&lt;br&gt;
keeps the engine on the frontend repository's release cadence, where that code and the&lt;br&gt;
people who know it live.&lt;/p&gt;

&lt;p&gt;Every failure collapses into one error class: bad circuit data, a non-2xx, a timeout, a&lt;br&gt;
non-JSON body, an empty result. That is deliberate. The only caller is a background job,&lt;br&gt;
and there is exactly one right answer to all of them — mark the run failed, leave the&lt;br&gt;
student's grade alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;Five merged, eight open, three written and waiting. The protocol layer is done and&lt;br&gt;
merged. Grade passback, deep linking, and roster sync are all in the queue. Autograding is&lt;br&gt;
started.&lt;/p&gt;

&lt;p&gt;The plan grew by three pull requests this week, which on a burndown chart is a bad week.&lt;br&gt;
It is the most useful thing I did. I have spent eleven weeks getting good at making&lt;br&gt;
mechanisms work, and the evaluation caught me having built most of a system that no&lt;br&gt;
instructor could have used, because I kept reading a feature request as a description of&lt;br&gt;
a machine instead of a description of a person trying to get something done.&lt;/p&gt;

&lt;p&gt;Next week: the editor lands, and then the settings and the grading result behind it.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>opensource</category>
      <category>career</category>
      <category>learning</category>
    </item>
    <item>
      <title>Week 10: The Handshake Begins</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Sat, 08 Aug 2026 02:21:38 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-10-the-handshake-begins-3j5n</link>
      <guid>https://dev.to/magic-peach/week-10-the-handshake-begins-3j5n</guid>
      <description>&lt;p&gt;This week the first leg of the LTI 1.3 handshake shipped as&lt;br&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7746" rel="noopener noreferrer"&gt;#7746&lt;/a&gt;, a static analyser and I&lt;br&gt;
disagreed about CSRF, and I spent an afternoon cleaning up a database mess I had made&lt;br&gt;
for myself weeks earlier.&lt;/p&gt;
&lt;h2&gt;
  
  
  OIDC login initiation
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;/lti/login&lt;/code&gt;, and it is the URL that the tool configuration from&lt;br&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7659" rel="noopener noreferrer"&gt;#7659&lt;/a&gt; has been advertising as&lt;br&gt;
&lt;code&gt;oidc_initiation_url&lt;/code&gt; for three weeks while returning 404.&lt;/p&gt;

&lt;p&gt;An LMS calls it with &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;login_hint&lt;/code&gt;, and optionally &lt;code&gt;client_id&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;lti_deployment_id&lt;/code&gt;. CircuitVerse resolves those to a registered deployment, mints a&lt;br&gt;
one-time nonce and a signed state, and redirects the browser to the platform's&lt;br&gt;
authorization endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;302 Found
location: http://canvas.docker/api/lti/authorize_redirect?scope=openid
  &amp;amp;response_type=id_token&amp;amp;response_mode=form_post&amp;amp;prompt=none
  &amp;amp;client_id=...&amp;amp;redirect_uri=.../lti/launch&amp;amp;login_hint=demo-student
  &amp;amp;nonce=ddbf28cc...&amp;amp;state=eyJfcmFpbHMi...--e3a4d84925a8df11c8dc6a...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;response_mode=form_post&lt;/code&gt; and &lt;code&gt;prompt=none&lt;/code&gt; are what make the platform POST the token&lt;br&gt;
straight back rather than showing a login screen. The &lt;code&gt;--&lt;/code&gt; in the state is the HMAC&lt;br&gt;
separator — that is the signed-state design from week 6 finally in production form.&lt;/p&gt;

&lt;p&gt;64 lines of controller code, about 150 lines of tests.&lt;/p&gt;
&lt;h2&gt;
  
  
  Arguing with CodeQL
&lt;/h2&gt;

&lt;p&gt;CI came back with CodeQL failing: &lt;em&gt;CSRF protection weakened or disabled.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It was pointing at this line, which I had edited to add the new action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;skip_before_action&lt;/span&gt; &lt;span class="ss"&gt;:verify_authenticity_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="sx"&gt;%i[launch oidc_login]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first reaction was that this is a false positive. Of course there is no CSRF token —&lt;br&gt;
the request originates from Canvas. There is no way for an LMS to hold a token minted by&lt;br&gt;
our forms.&lt;/p&gt;

&lt;p&gt;My second reaction, which took longer, was that the scanner was pointing at something&lt;br&gt;
real even if its conclusion did not fit. &lt;code&gt;skip_before_action&lt;/code&gt; is a blunt instrument: it&lt;br&gt;
turns the check off for the entire action, for every request shape, forever. What I&lt;br&gt;
actually wanted to say is narrower — &lt;em&gt;this particular kind of request does not need a&lt;br&gt;
token, and everything else still does.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So instead of skipping, I overrode the predicate the check uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verified_request?&lt;/span&gt;
  &lt;span class="k"&gt;super&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action_name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"oidc_login"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:iss&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:login_hint&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Token verification stays fully active for every other action and every other request&lt;br&gt;
shape. Only a well-formed LTI initiation is treated as verified. And the justification&lt;br&gt;
holds up: the action reads no session, writes nothing, and the signed state it returns is&lt;br&gt;
what actually protects the launch that follows.&lt;/p&gt;

&lt;p&gt;I checked this was real rather than incidental by removing the override and running the&lt;br&gt;
tests — the cross-site POST spec fails with &lt;code&gt;InvalidAuthenticityToken&lt;/code&gt;. Worth doing,&lt;br&gt;
because a test that passes for the wrong reason is worse than no test. The test&lt;br&gt;
environment disables forgery protection by default, so that spec has to switch it back&lt;br&gt;
on explicitly or it proves nothing at all.&lt;/p&gt;

&lt;p&gt;CodeQL went green, and the code is genuinely narrower than what I started with. The&lt;br&gt;
scanner was more right than I initially gave it credit for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What review caught
&lt;/h2&gt;

&lt;p&gt;Three findings on the pull request, all worth having:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambiguous deployment matches.&lt;/strong&gt; My lookup ended with &lt;code&gt;scope.order(:id).first&lt;/code&gt;. Since&lt;br&gt;
&lt;code&gt;client_id&lt;/code&gt; is optional in an initiation, a platform with several registrations under&lt;br&gt;
one issuer would silently resolve to whichever row happened to have the lowest ID — and&lt;br&gt;
the launch would be sent to a different registration's client ID and authorization&lt;br&gt;
endpoint. Now an ambiguous match is refused with a 404 rather than guessed at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-HTTPS authorization endpoints.&lt;/strong&gt; The &lt;code&gt;LtiDeployment&lt;/code&gt; model only validates that&lt;br&gt;
&lt;code&gt;auth_login_url&lt;/code&gt; is present, not that it is a sane URL. Since the redirect uses&lt;br&gt;
&lt;code&gt;allow_other_host: true&lt;/code&gt;, a malformed or hostile value in that column goes straight to&lt;br&gt;
the browser. A &lt;code&gt;javascript:&lt;/code&gt; URL is now rejected before it can reach &lt;code&gt;redirect_to&lt;/code&gt;, and&lt;br&gt;
TLS is required in production while plain http still works locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Registered query parameters being discarded.&lt;/strong&gt; Some platforms register an&lt;br&gt;
authorization endpoint that already carries query parameters. I was replacing the query&lt;br&gt;
string wholesale. The suggested fix merged instead of replaced — and introduced a subtle&lt;br&gt;
bug of its own by merging symbol keys into a hash with string keys, so a platform's own&lt;br&gt;
&lt;code&gt;scope=&lt;/code&gt; would have survived alongside ours as a duplicate parameter. String keys&lt;br&gt;
throughout fixed it.&lt;/p&gt;

&lt;p&gt;That last one is a good reminder that a suggested patch is a suggestion. Both PRs this&lt;br&gt;
week had a fix applied through the GitHub web UI that did not splice cleanly — one&lt;br&gt;
duplicated the tail of a method, leaving four unreachable statements and a duplicate&lt;br&gt;
&lt;code&gt;rescue&lt;/code&gt; clause that failed lint. Convenient, but it still needs reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database mess
&lt;/h2&gt;

&lt;p&gt;Somewhere in this week I tried to run the new endpoint against my development database&lt;br&gt;
and got a &lt;code&gt;NOT NULL&lt;/code&gt; violation on a column that does not exist in the merged schema.&lt;/p&gt;

&lt;p&gt;The cause was archaeology. My abandoned POC branch from week 2 had a migration creating&lt;br&gt;
&lt;code&gt;lti_deployments&lt;/code&gt; with a &lt;code&gt;platform_id&lt;/code&gt; column. I ran it against my dev database in June.&lt;br&gt;
The branch was closed; the migration was never rolled back. The merged version of that&lt;br&gt;
table, from a completely different pull request, has no &lt;code&gt;platform_id&lt;/code&gt; — so my dev&lt;br&gt;
database had a table that matched no branch anyone was working on.&lt;/p&gt;

&lt;p&gt;Tracing it was more interesting than expected. The physical column order gave it away:&lt;br&gt;
&lt;code&gt;platform_public_key&lt;/code&gt; sat &lt;em&gt;after&lt;/em&gt; the timestamps, which meant it had been added by a&lt;br&gt;
later &lt;code&gt;add_column&lt;/code&gt; rather than declared inline — which pinned it to the March POC&lt;br&gt;
migration rather than the June one. Twenty-six migration versions were recorded in my&lt;br&gt;
dev database with no corresponding file on any branch, along with an orphaned&lt;br&gt;
&lt;code&gt;lti_platforms&lt;/code&gt; table whose migration no longer exists anywhere.&lt;/p&gt;

&lt;p&gt;I cleaned it surgically rather than dropping the database: exported the two Canvas&lt;br&gt;
registrations my demo depends on, dropped the drifted columns and tables, cleared the&lt;br&gt;
stale migration stamps, ran the real migration, and re-imported the registrations&lt;br&gt;
against the new schema.&lt;/p&gt;

&lt;p&gt;The lesson is cheap to state and I had to learn it the expensive way: &lt;strong&gt;migrations you&lt;br&gt;
run from a branch that never merges do not clean themselves up.&lt;/strong&gt; A dev database is&lt;br&gt;
long-lived state that accumulates the residue of every experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;Merged: the 1.1 grade passback fix, and the deployment model. Approved and waiting: the&lt;br&gt;
JWKS and tool configuration endpoints, the JWT validator, and now the OIDC login.&lt;/p&gt;

&lt;p&gt;That is four of the twenty-four, with the whole of phase 1's protocol layer either&lt;br&gt;
merged or in the queue.&lt;/p&gt;

&lt;p&gt;Next is the launch itself — verify the state, validate the token through the validator&lt;br&gt;
from week 9, bind the token's deployment to the one the state was issued for, and sign&lt;br&gt;
the user in on their &lt;code&gt;sub&lt;/code&gt;. It is the piece where all the separate bricks finally do&lt;br&gt;
something a user can see: a Canvas click landing a signed-in user inside CircuitVerse.&lt;/p&gt;

&lt;p&gt;It is also the one I need to be most careful with, and I have a list of open questions&lt;br&gt;
to settle before writing it — what happens when a platform's privacy settings mean it&lt;br&gt;
sends no email address at all, what happens when that email already belongs to a&lt;br&gt;
CircuitVerse account, and where the nonce gets recorded so a state cannot be replayed&lt;br&gt;
inside its five-minute window.&lt;/p&gt;

&lt;p&gt;Ten weeks ago I would have just written it and found out.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>circuitverse</category>
      <category>lti</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Week 9: Verifying the Token</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Sat, 08 Aug 2026 02:19:37 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-9-verifying-the-token-4mmg</link>
      <guid>https://dev.to/magic-peach/week-9-verifying-the-token-4mmg</guid>
      <description>&lt;p&gt;Two pull requests merged this week and the most security-critical piece of the whole&lt;br&gt;
project went out for review on its own. This was the week the plan from week 7 started&lt;br&gt;
visibly paying off.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two merges
&lt;/h2&gt;

&lt;p&gt;On 28 July, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7654" rel="noopener noreferrer"&gt;#7654&lt;/a&gt; merged — the&lt;br&gt;
&lt;code&gt;LtiDeployment&lt;/code&gt; model. CircuitVerse can now store which LMS platforms it trusts: the&lt;br&gt;
issuer, the client ID, the deployment ID, and the platform's endpoint URLs, unique per&lt;br&gt;
issuer + client + deployment because that triple is how LTI 1.3 identifies a deployment.&lt;/p&gt;

&lt;p&gt;Nothing reads the table yet. That is the point of building this way, and it is the thing&lt;br&gt;
that needs explaining in every PR description.&lt;/p&gt;
&lt;h2&gt;
  
  
  The validator
&lt;/h2&gt;

&lt;p&gt;The main work was &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7715" rel="noopener noreferrer"&gt;#7715&lt;/a&gt;:&lt;br&gt;
&lt;code&gt;Lti::JwtValidator&lt;/code&gt;, a class with one public method.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JwtValidator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give it an &lt;code&gt;id_token&lt;/code&gt;, the deployment it should belong to, and the nonce we issued. It&lt;br&gt;
either returns the verified payload or raises. No controller, no session, no Rails&lt;br&gt;
request object. That isolation was forced by the constraint that it had to be its own&lt;br&gt;
pull request, and it is a much better boundary than I would have drawn otherwise.&lt;/p&gt;
&lt;h3&gt;
  
  
  What it has to refuse
&lt;/h3&gt;

&lt;p&gt;The tests are mostly attacks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A token signed by the wrong key.&lt;/strong&gt; The baseline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A token with &lt;code&gt;alg: none&lt;/code&gt;.&lt;/strong&gt; Early JWT libraries would accept an unsigned token if it&lt;br&gt;
declared its algorithm as &lt;code&gt;none&lt;/code&gt;. Any validator that reads the algorithm out of the&lt;br&gt;
token and trusts it is exploitable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An HS256 token where RS256 is expected.&lt;/strong&gt; This is algorithm confusion, and it is the&lt;br&gt;
subtle one. RS256 is asymmetric: the platform signs with a private key, we verify with&lt;br&gt;
their public key, which is published. HS256 is symmetric: the same secret signs and&lt;br&gt;
verifies. If a validator takes the algorithm from the token header and passes the&lt;br&gt;
platform's public key as the key, an attacker can sign a token using that &lt;em&gt;public&lt;/em&gt; key&lt;br&gt;
as an HMAC secret — and the validator verifies it, because it is doing exactly what it&lt;br&gt;
was told. The public key is public, so anyone can do this.&lt;/p&gt;

&lt;p&gt;The defence is to never read the algorithm from the token. Pass an explicit allow-list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;encoded_token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify_signature!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;algorithm: &lt;/span&gt;&lt;span class="s2"&gt;"RS256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;key: &lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expired, wrong issuer, wrong audience, missing subject, replayed nonce&lt;/strong&gt;, and the&lt;br&gt;
multi-audience case: when &lt;code&gt;aud&lt;/code&gt; is an array with more than one entry, the &lt;code&gt;azp&lt;/code&gt; claim&lt;br&gt;
must name our client ID.&lt;/p&gt;
&lt;h3&gt;
  
  
  Resolving the platform's key
&lt;/h3&gt;

&lt;p&gt;The other half is fetching the right public key. A platform publishes a JWKS endpoint;&lt;br&gt;
the token header carries a &lt;code&gt;kid&lt;/code&gt; identifying which key signed it.&lt;/p&gt;

&lt;p&gt;Two things made this more interesting than expected:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key rotation.&lt;/strong&gt; Platforms rotate keys. A cached key set will not contain a newly&lt;br&gt;
rotated key. So a &lt;code&gt;kid&lt;/code&gt; that is missing from the cache is treated as a signal that&lt;br&gt;
rotation may have happened, and triggers a refetch rather than a wait for expiry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caching.&lt;/strong&gt; Without it, every single launch makes an outbound HTTP request to the&lt;br&gt;
platform before anyone can sign in. If the platform's JWKS endpoint is slow, every login&lt;br&gt;
is slow; if it is down, every login is down. A short-lived cache keyed on the JWKS URL&lt;br&gt;
fixes that, with the rotation behaviour above as the escape hatch.&lt;/p&gt;
&lt;h3&gt;
  
  
  What review found
&lt;/h3&gt;

&lt;p&gt;The bot review flagged two failure modes I had not handled, and both were correct:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A blank &lt;code&gt;jwks_url&lt;/code&gt;.&lt;/strong&gt; A deployment configured with only a stored public key and no&lt;br&gt;
JWKS URL would still make an HTTP request, because a blank URL parses as a relative path&lt;br&gt;
rather than failing. So every launch for that deployment took a pointless network trip&lt;br&gt;
before falling back. One guard clause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Malformed stored key data.&lt;/strong&gt; &lt;code&gt;OpenSSL::PKey::RSA.new&lt;/code&gt; raises &lt;code&gt;OpenSSL::PKey::RSAError&lt;/code&gt;&lt;br&gt;
on bad PEM data, and I was only rescuing &lt;code&gt;JWT::DecodeError&lt;/code&gt;. So a misconfigured&lt;br&gt;
deployment record would propagate an OpenSSL exception out of a method whose entire&lt;br&gt;
contract is "raises &lt;code&gt;ValidationError&lt;/code&gt;". Callers written against that contract would not&lt;br&gt;
catch it.&lt;/p&gt;

&lt;p&gt;That second one is a good example of a bug that is invisible while you are writing the&lt;br&gt;
code and obvious once named. My mental model was "this method validates tokens, so it&lt;br&gt;
raises validation errors." The actual behaviour was "this method raises validation&lt;br&gt;
errors &lt;em&gt;unless the database row is malformed&lt;/em&gt;, in which case it raises something else&lt;br&gt;
entirely."&lt;/p&gt;

&lt;p&gt;I fixed both in &lt;code&gt;fix(lti): harden JwtValidator key resolution per review&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The refactor request
&lt;/h2&gt;

&lt;p&gt;Late in the week, a maintainer asked something more interesting than a bug report:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;could you suggest refactoring this class using JWT::Token or JWT::EncodedToken&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My implementation called &lt;code&gt;JWT.decode&lt;/code&gt; twice — once with verification disabled to read&lt;br&gt;
the header and pick a key by &lt;code&gt;kid&lt;/code&gt;, then again with verification on. That works, and it&lt;br&gt;
has a smell: there is a window where the code holds an unverified, parsed token, and&lt;br&gt;
nothing but my own discipline stops me from reading a claim out of it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;JWT::EncodedToken&lt;/code&gt; closes that window at the library level. &lt;code&gt;#payload&lt;/code&gt; refuses to&lt;br&gt;
decode until both the signature and the claims have been verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;payload&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;JWT&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DecodeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="vi"&gt;@signature_verified&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;JWT&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DecodeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="vi"&gt;@claims_verified&lt;/span&gt;
  &lt;span class="n"&gt;decoded_payload&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header is still read unverified, which is unavoidable — you cannot pick a key&lt;br&gt;
without knowing which key was used — but it is only ever used to select a candidate key,&lt;br&gt;
and the ordering the class used to depend on by convention is now enforced by the&lt;br&gt;
library.&lt;/p&gt;

&lt;p&gt;That is a strictly better property: the difference between "the code is correct" and&lt;br&gt;
"the code cannot easily be made incorrect by the next person to edit it."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am taking from the week
&lt;/h2&gt;

&lt;p&gt;The isolation was the win. Because the validator was its own pull request, with no&lt;br&gt;
controller and no session attached, both the review comments were about &lt;em&gt;the validator&lt;/em&gt;&lt;br&gt;
— its error contract, its failure modes — rather than about whether the launch flow&lt;br&gt;
around it made sense.&lt;/p&gt;

&lt;p&gt;Compare that to my POC, where the same class was buried in 2,000 lines and nobody looked&lt;br&gt;
at it at all.&lt;/p&gt;

&lt;p&gt;Next week: the first half of the handshake ships, and a scanner disagrees with me about&lt;br&gt;
CSRF.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>circuitverse</category>
      <category>lti</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Week 8: The First Merge, and Learning to Wait</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Sat, 08 Aug 2026 02:18:20 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-8-the-first-merge-and-learning-to-wait-1pa2</link>
      <guid>https://dev.to/magic-peach/week-8-the-first-merge-and-learning-to-wait-1pa2</guid>
      <description>&lt;p&gt;On 26 July, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7647" rel="noopener noreferrer"&gt;#7647&lt;/a&gt; merged. It&lt;br&gt;
was the first piece of my project to land in CircuitVerse's master branch, fourteen&lt;br&gt;
days after I opened it and about seven weeks into the project.&lt;/p&gt;

&lt;p&gt;It is a small change. It makes sure an LTI 1.1 grade is written to CircuitVerse's&lt;br&gt;
database before it is pushed to the LMS gradebook, so the two cannot silently disagree&lt;br&gt;
when a local save fails. Plus the fix from last week's review — clearing stale grading&lt;br&gt;
context only after a launch has been verified, so a forged request cannot wipe a&lt;br&gt;
teacher's session on its way to being rejected.&lt;/p&gt;

&lt;p&gt;Most of this week was spent not writing code, which is a thing I want to write about&lt;br&gt;
honestly rather than skip over.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually shipped
&lt;/h2&gt;

&lt;p&gt;The merged change has two halves, and the second only exists because of review.&lt;/p&gt;

&lt;p&gt;The first is ordering: persist the grade, then push it to the LMS. Previously the&lt;br&gt;
passback ran first, so a local save that failed validation left the gradebook holding a&lt;br&gt;
score CircuitVerse had no record of. The LMS is the more visible system, which makes&lt;br&gt;
this the worse direction to be wrong in — a student and an instructor both see a grade&lt;br&gt;
that does not exist anywhere in our database.&lt;/p&gt;

&lt;p&gt;The second is the fix from &lt;code&gt;anxkhn&lt;/code&gt;'s review: stale grading context is now cleared only&lt;br&gt;
after a launch has been verified, not before. An unauthenticated request that gets&lt;br&gt;
rejected can no longer wipe a teacher's in-progress grading session on its way out.&lt;/p&gt;

&lt;p&gt;Both are small. Neither is glamorous. Together they mean the gradebook and CircuitVerse&lt;br&gt;
cannot silently disagree, in an integration that schools are using today, which is worth&lt;br&gt;
more than another thousand lines of the 1.3 work sitting unmerged on a branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the week
&lt;/h2&gt;

&lt;p&gt;My commit graph for 16–25 July is empty. Two pull requests were open and waiting&lt;br&gt;
(&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7654" rel="noopener noreferrer"&gt;#7654&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7659" rel="noopener noreferrer"&gt;#7659&lt;/a&gt;), one was awaiting the&lt;br&gt;
final look before merge, and the next thing in the plan — the JWT validator — depended&lt;br&gt;
on decisions inside them.&lt;/p&gt;

&lt;p&gt;Early in the project, a week like this would have gone one of two ways. Either I would&lt;br&gt;
have sat refreshing the PR page, or I would have started building three PRs ahead and&lt;br&gt;
piled up work that might need reworking depending on how the review went.&lt;/p&gt;

&lt;p&gt;I had done the second thing already, in weeks 2 and 3, and it is how I ended up with a&lt;br&gt;
2,000 line branch nobody could review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What review latency actually is
&lt;/h2&gt;

&lt;p&gt;The reframe that helped: maintainer response time is not an obstacle in the process. It&lt;br&gt;
&lt;em&gt;is&lt;/em&gt; the process.&lt;/p&gt;

&lt;p&gt;CircuitVerse's maintainers review contributions alongside their own jobs. A day or two&lt;br&gt;
of latency on a pull request is not neglect, it is the normal operating speed of a&lt;br&gt;
volunteer-maintained project. Every plan I make has to be built for that speed rather&lt;br&gt;
than in spite of it.&lt;/p&gt;

&lt;p&gt;Concretely, that changes what "being blocked" means. If PR N is in review and PR N+1&lt;br&gt;
depends on it, I am not blocked from working — I am blocked from &lt;em&gt;committing to a&lt;br&gt;
particular implementation&lt;/em&gt; of N+1. Those are different, and the gap between them is&lt;br&gt;
where the useful work of a waiting week lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did with it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read the review comments properly.&lt;/strong&gt; Not just applying them, but working out the&lt;br&gt;
general rule behind each. &lt;code&gt;anxkhn&lt;/code&gt;'s note about clearing grading context before&lt;br&gt;
verification was specifically about one method, but the rule it implies — nothing that&lt;br&gt;
mutates state may happen before the security check, including deletes — is one I could&lt;br&gt;
then apply to code they had not looked at. A review comment is a sample from a&lt;br&gt;
distribution of mistakes I am prone to. Fixing the sample is the minimum; finding the&lt;br&gt;
distribution is the value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrote the next PR's tests before its code.&lt;/strong&gt; The JWT validator was next, and while I&lt;br&gt;
did not want to commit to its implementation before the deployment model merged, the&lt;br&gt;
&lt;em&gt;attacks it must survive&lt;/em&gt; did not depend on any of that. A token signed with the wrong&lt;br&gt;
key. A token with &lt;code&gt;alg: none&lt;/code&gt;. An HS256 token where the validator expects RS256 —&lt;br&gt;
algorithm confusion, where an attacker signs with the public key as an HMAC secret and a&lt;br&gt;
naive validator accepts it. Expired tokens, wrong issuer, wrong audience, missing&lt;br&gt;
subject, replayed nonce.&lt;/p&gt;

&lt;p&gt;Writing those first meant that when the validator got written, its shape was determined&lt;br&gt;
by what it had to refuse rather than by what I found convenient to implement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read other people's LTI implementations.&lt;/strong&gt; Specifically for how they handle key&lt;br&gt;
rotation — a platform can publish a new signing key at any time, and a tool that caches&lt;br&gt;
the key set has to notice. The pattern I settled on: cache the key set, but if a token&lt;br&gt;
arrives with a &lt;code&gt;kid&lt;/code&gt; that is not in the cache, refetch immediately rather than waiting&lt;br&gt;
for the cache to expire. Rotation is exactly the case where the cache is stale, so&lt;br&gt;
treating a cache miss as a rotation signal is the right default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kept the queue moving.&lt;/strong&gt; The point of the twenty-four PR plan is a steady stream of&lt;br&gt;
small reviewable things. That only works if the queue is never empty from my side, and&lt;br&gt;
never so full that it becomes a burden. Two open at a time, both small, both green,&lt;br&gt;
turned out to be about right.&lt;/p&gt;

&lt;h2&gt;
  
  
  On the size of the win
&lt;/h2&gt;

&lt;p&gt;Fourteen days from open to merge, for a change of about 150 lines that fixes a real bug&lt;br&gt;
in a feature real users have today.&lt;/p&gt;

&lt;p&gt;Measured in lines of code per day it is not much. But the previous seven weeks produced&lt;br&gt;
zero merged lines, and the thing that changed was not that I started working harder — it&lt;br&gt;
was that I started sending work in a form somebody could say yes to.&lt;/p&gt;

&lt;p&gt;That is the actual lesson of the first half of this project, and I could not have&lt;br&gt;
learned it by reading about it. I had to spend three weeks building something good that&lt;br&gt;
could not merge.&lt;/p&gt;

&lt;p&gt;There is a version of this project where I never write the POC, go straight to small&lt;br&gt;
PRs, and have six things merged by now. I do not think I would have understood &lt;em&gt;why&lt;/em&gt;&lt;br&gt;
they had to be small. The POC being closed is what made the constraint make sense rather&lt;br&gt;
than feel like bureaucracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where things stand
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merged:&lt;/strong&gt; the 1.1 grade passback fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approved and waiting:&lt;/strong&gt; the deployment model, and the JWKS and tool configuration
endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next:&lt;/strong&gt; the JWT validator — the security-critical brick, reviewed on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next week: the token validator, and finding out my key-resolution code had two failure&lt;br&gt;
modes I had not handled.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>circuitverse</category>
      <category>lti</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Week 7: Creating phases for the tasks</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Sat, 08 Aug 2026 02:16:10 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-7-twenty-four-pull-requests-46ff</link>
      <guid>https://dev.to/magic-peach/week-7-twenty-four-pull-requests-46ff</guid>
      <description>&lt;p&gt;This was the week the project got a plan it could actually execute, and the week a&lt;br&gt;
reviewer found a security bug in a fix I had already convinced myself was correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review
&lt;/h2&gt;

&lt;p&gt;I had opened &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7647" rel="noopener noreferrer"&gt;#7647&lt;/a&gt; at the end&lt;br&gt;
of last week — the small fix ensuring an LTI 1.1 grade is saved locally before being&lt;br&gt;
pushed to the LMS.&lt;/p&gt;

&lt;p&gt;On 14 July, &lt;code&gt;anxkhn&lt;/code&gt; reviewed it. The first comment was housekeeping: rebase and look&lt;br&gt;
at failing CI. I rebased, tests went green, and I said so.&lt;/p&gt;

&lt;p&gt;The second comment is the one worth writing about:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;storing the context is fixed, but it is still cleared before &lt;code&gt;valid_request?&lt;/code&gt;. for&lt;br&gt;
example, a fake launch could clear a teacher's valid grading session and prevent the&lt;br&gt;
next grade passback. please clear it only after a valid launch too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Let me unpack what that means, because it is genuinely subtle.&lt;/p&gt;

&lt;p&gt;An LTI 1.1 launch stores some grading context in the session — the outcome service URL&lt;br&gt;
the LMS wants scores posted to, and which assignment is being graded. My change made&lt;br&gt;
sure that context is written &lt;em&gt;after&lt;/em&gt; the launch signature is verified. Good.&lt;/p&gt;

&lt;p&gt;But the &lt;em&gt;clearing&lt;/em&gt; of stale context happened in a &lt;code&gt;before_action&lt;/code&gt;, which runs before&lt;br&gt;
&lt;code&gt;valid_request?&lt;/code&gt; — before we know whether the launch is genuine at all.&lt;/p&gt;

&lt;p&gt;So: a teacher launches from Canvas, gets a valid grading session, starts grading.&lt;br&gt;
Meanwhile anyone can POST garbage at &lt;code&gt;/lti/launch&lt;/code&gt; with no valid signature. That request&lt;br&gt;
gets rejected — 401, correct — but on its way to being rejected it has already wiped&lt;br&gt;
the teacher's grading context. Their next grade passback silently fails.&lt;/p&gt;

&lt;p&gt;An unauthenticated request cannot sign in, cannot read anything, cannot write&lt;br&gt;
anything — and can still degrade a legitimate user's session as a side effect of being&lt;br&gt;
rejected.&lt;/p&gt;

&lt;p&gt;I had read that code many times. I had written the fix for the adjacent bug in the same&lt;br&gt;
method. I did not see it, because I was asking "is the write ordered correctly?" and the&lt;br&gt;
bug was in the delete.&lt;/p&gt;

&lt;p&gt;What I take from it: &lt;strong&gt;when you fix ordering around a security check, audit every&lt;br&gt;
operation on that path, not just the one you came for.&lt;/strong&gt; The invariant is not "the write&lt;br&gt;
happens after verification." It is "nothing that affects state happens before&lt;br&gt;
verification." I had fixed one instance of a general rule while leaving another in&lt;br&gt;
place.&lt;/p&gt;

&lt;p&gt;I also want to record that this is the value of review that no amount of care&lt;br&gt;
substitutes for. Not because the reviewer knew something I did not, but because they&lt;br&gt;
came to the code without my assumption about which line was interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other review comment
&lt;/h2&gt;

&lt;p&gt;The same day, &lt;code&gt;anxkhn&lt;/code&gt; reviewed&lt;br&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7654" rel="noopener noreferrer"&gt;#7654&lt;/a&gt;, the &lt;code&gt;LtiDeployment&lt;/code&gt;&lt;br&gt;
model, and approved it — with a nitpick:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;could you change &lt;code&gt;Fixes #7405&lt;/code&gt; to &lt;code&gt;Part of #7405&lt;/code&gt;? this PR is the first foundation&lt;br&gt;
step, so merging it should not close the larger issue.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Small, and it corrected a real misunderstanding. I had been writing &lt;code&gt;Fixes #7405&lt;/code&gt; on&lt;br&gt;
every LTI pull request out of habit. GitHub's auto-close keywords mean the first one to&lt;br&gt;
merge would have closed the umbrella issue tracking the entire LTI 1.3 upgrade — with&lt;br&gt;
about 90% of it unbuilt.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Part of #7405&lt;/code&gt; links without closing. I have used that phrasing on every LTI PR since.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty-four pull requests
&lt;/h2&gt;

&lt;p&gt;The bigger piece of the week was planning. On 14 July I wrote out the remaining work as&lt;br&gt;
a sequence of pull requests, and the constraint I set was that no single one should&lt;br&gt;
exceed roughly 200 hand-written lines. Generated files like &lt;code&gt;Gemfile.lock&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;db/schema.rb&lt;/code&gt; do not count, but get flagged for reviewers.&lt;/p&gt;

&lt;p&gt;It came to twenty-four.&lt;/p&gt;

&lt;p&gt;The number was startling and also clarifying. What used to be "implement LTI 1.3" became&lt;br&gt;
a dependency-ordered queue, with phases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase 0&lt;/strong&gt; — the credibility work: fix the 1.1 bug that already affects real users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1&lt;/strong&gt; — the launch core, five PRs: gems and the deployment model; key manager
and public endpoints; the JWT validator alone; OIDC login initiation; the launch
itself; then the resource link model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2&lt;/strong&gt; — Assignment and Grade Services, so grades flow to the LMS gradebook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 3&lt;/strong&gt; — Deep Linking, so an instructor can embed specific CircuitVerse content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 4&lt;/strong&gt; — Names and Roles, so the roster syncs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 5&lt;/strong&gt; — autograding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing that surprised me is how much the constraint improved the &lt;em&gt;design&lt;/em&gt;, not just&lt;br&gt;
the delivery. Forced to make &lt;code&gt;Lti::JwtValidator&lt;/code&gt; a standalone pull request, it has to be&lt;br&gt;
a class that takes a token and a deployment and returns a payload — no controller, no&lt;br&gt;
session, no Rails request. That is a better boundary than I would have drawn if I were&lt;br&gt;
free to let it reach into whatever it wanted.&lt;/p&gt;

&lt;p&gt;The same happened to the OIDC login. Made to stand alone, it has to hand back a state&lt;br&gt;
that the launch can verify later with no shared memory between them — which is exactly&lt;br&gt;
the signed-state design from last week. The constraint and the correct architecture&lt;br&gt;
pointed the same way.&lt;/p&gt;

&lt;p&gt;Two rules I wrote down alongside the plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If a PR crosses ~200 lines, split the spec edge cases into a follow-up — &lt;strong&gt;never&lt;/strong&gt;
thin out security validation to fit the budget.&lt;/li&gt;
&lt;li&gt;Every PR description names which future PR depends on it, so a maintainer can see why
a change with no user-visible behaviour deserves to merge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That second rule is doing a lot of work. The hardest thing about this approach is that&lt;br&gt;
most of the PRs do nothing on their own. A deployments table nothing reads. A validator&lt;br&gt;
nothing calls. Without the dependency note, each looks like dead code, and "we don't&lt;br&gt;
merge unused code" is a reasonable instinct for a maintainer to have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping against the plan
&lt;/h2&gt;

&lt;p&gt;Two PRs went out under the new scheme:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7654" rel="noopener noreferrer"&gt;#7654&lt;/a&gt; — the &lt;code&gt;LtiDeployment&lt;/code&gt;&lt;br&gt;
model: issuer, client ID, deployment ID, and the platform's endpoint URLs, unique per&lt;br&gt;
issuer + client + deployment. 97 lines. Storage only, nothing reads it yet. Approved&lt;br&gt;
the day it was reviewed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7659" rel="noopener noreferrer"&gt;#7659&lt;/a&gt; — the JWKS and tool&lt;br&gt;
configuration endpoints, plus a key manager that owns CircuitVerse's own RSA keypair.&lt;br&gt;
This is what makes CircuitVerse &lt;em&gt;registerable&lt;/em&gt;: an LMS admin fetches the tool&lt;br&gt;
configuration, and the platform can fetch our public key to verify anything we sign.&lt;br&gt;
193 lines, behind the flag.&lt;/p&gt;

&lt;p&gt;Both were reviewable in a sitting. Which, compared to a 2,000 line POC closed the next&lt;br&gt;
day, is the whole difference.&lt;/p&gt;

&lt;p&gt;Next week: waiting, and what I did with it.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>circuitverse</category>
      <category>opensource</category>
      <category>planning</category>
    </item>
    <item>
      <title>Week 6: Signing the State, and Closing My Own Pull Requests</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Sat, 08 Aug 2026 02:13:42 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-6-signing-the-state-and-closing-my-own-pull-requests-4nkb</link>
      <guid>https://dev.to/magic-peach/week-6-signing-the-state-and-closing-my-own-pull-requests-4nkb</guid>
      <description>&lt;p&gt;Three things happened this week: I undid the cookie change from week 5 with something&lt;br&gt;
better, I put the entire LTI 1.3 feature behind a flag that defaults to off, and I&lt;br&gt;
closed two of my own pull requests.&lt;/p&gt;
&lt;h2&gt;
  
  
  Undoing the cookie
&lt;/h2&gt;

&lt;p&gt;Last week I set &lt;code&gt;SameSite=None&lt;/code&gt; on the session cookie so it would survive the&lt;br&gt;
cross-site POST that carries the &lt;code&gt;id_token&lt;/code&gt; back from the platform. It worked, and it&lt;br&gt;
weakened a CSRF defence across the whole application to serve one endpoint.&lt;/p&gt;

&lt;p&gt;This week's commit is&lt;br&gt;
&lt;code&gt;fix(lti): sign the OIDC state instead of weakening the session cookie&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The reframe that unlocked it: I had been treating "the state must survive the round&lt;br&gt;
trip" as "the state must be &lt;em&gt;stored&lt;/em&gt; somewhere that survives the round trip." Those are&lt;br&gt;
not the same requirement. The state does not need to be stored at all. It needs to be&lt;br&gt;
&lt;em&gt;recognisable&lt;/em&gt; when it comes back.&lt;/p&gt;

&lt;p&gt;So instead of keeping a value in the session and comparing, the state becomes a signed,&lt;br&gt;
self-contained token carrying its own payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;LTI_STATE_PURPOSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lti.launch.state"&lt;/span&gt;
&lt;span class="no"&gt;LTI_STATE_TTL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;

&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lti_state_verifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"nonce"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"deployment_id"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="ss"&gt;purpose: &lt;/span&gt;&lt;span class="no"&gt;LTI_STATE_PURPOSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;expires_in: &lt;/span&gt;&lt;span class="no"&gt;LTI_STATE_TTL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Rails.application.message_verifier&lt;/code&gt; signs it with the application's secret. When the&lt;br&gt;
launch returns, we verify the signature, and if it checks out we know the contents came&lt;br&gt;
from us and have not been altered. The nonce and the deployment ID ride inside the&lt;br&gt;
token itself.&lt;/p&gt;

&lt;p&gt;What this buys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No session, so no cookie problem.&lt;/strong&gt; Nothing needs to survive the cross-site POST
except the parameter the platform is already sending back to us.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No cookie policy change.&lt;/strong&gt; &lt;code&gt;SameSite=Lax&lt;/code&gt; stays as it is for every other page on
the site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No server-side state.&lt;/strong&gt; No table, no cleanup job, no expiry sweeper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The signature is the CSRF defence.&lt;/strong&gt; Only CircuitVerse can mint a valid state, so a
launch carrying one is answering an initiation we started.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It expires.&lt;/strong&gt; Five minutes, enforced by the verifier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;purpose&lt;/code&gt; argument is a detail worth calling out. It scopes the signature to this&lt;br&gt;
one use, so a token signed elsewhere in the application with the same secret will not&lt;br&gt;
verify here. Same secret, different purposes, no cross-use.&lt;/p&gt;

&lt;p&gt;The general lesson: when a constraint blocks you, check whether you are solving the&lt;br&gt;
constraint or the requirement behind it. "Survive a cross-site POST" sounded like a&lt;br&gt;
cookie problem. It was a state-management problem, and moving the state out of the&lt;br&gt;
session dissolved the cookie question entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flag
&lt;/h2&gt;

&lt;p&gt;The commit &lt;code&gt;feat(lti): gate LTI 1.3 behind a default-off lti_advantage flag&lt;/code&gt; does what&lt;br&gt;
it says: every 1.3 endpoint returns 404 unless an operator turns the flag on.&lt;/p&gt;

&lt;p&gt;The reason is specific. The 1.3 launch &lt;strong&gt;auto-provisions user accounts&lt;/strong&gt;. A launch from&lt;br&gt;
a registered platform creates a CircuitVerse user, confirmed, ready to sign in. That is&lt;br&gt;
correct behaviour for the feature and it is also a significant thing to have live in&lt;br&gt;
production the moment a branch merges.&lt;/p&gt;

&lt;p&gt;A default-off flag means the code can land, be reviewed, be tested, and sit there inert&lt;br&gt;
until somebody deliberately switches it on. It decouples "this code is merged" from&lt;br&gt;
"this code is running", which for an auth path is exactly the separation you want.&lt;/p&gt;

&lt;p&gt;There was also a small commit removing some stale comments about CSRF token handling —&lt;br&gt;
notes I had written to myself that had stopped being true after the state change.&lt;br&gt;
Comments that describe an older version of the code are worse than no comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing two pull requests
&lt;/h2&gt;

&lt;p&gt;On 12 July I closed &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7510" rel="noopener noreferrer"&gt;#7510&lt;/a&gt;,&lt;br&gt;
the &lt;code&gt;lti-advantage&lt;/code&gt; gem addition, and&lt;br&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7632" rel="noopener noreferrer"&gt;#7632&lt;/a&gt;, the subgroups feature.&lt;/p&gt;

&lt;p&gt;The gem one was straightforward: I had already reversed that decision in week 4 and&lt;br&gt;
gone direct to &lt;code&gt;ruby-jwt&lt;/code&gt;. Leaving the PR open was just noise in the queue. Closing your&lt;br&gt;
own obsolete pull requests is a small courtesy — every open PR is something a maintainer&lt;br&gt;
has to look at at least once to decide it is not for them.&lt;/p&gt;

&lt;p&gt;Subgroups was harder, because that one was finished. Data layer, UI, controller, API&lt;br&gt;
endpoints, scoping, tests. Earlier in the week I had added the REST API v1 endpoints and&lt;br&gt;
fixed a bug where a synced user who was also a subgroup mentor got a duplicate&lt;br&gt;
membership row inserted.&lt;/p&gt;

&lt;p&gt;But it had the same problem as my original POC: it was a large change to a core model,&lt;br&gt;
arriving as one piece, at a moment when the maintainers' attention on my work was&lt;br&gt;
pointed at LTI. It was not going to get the review it needed, and holding it open would&lt;br&gt;
not change that.&lt;/p&gt;

&lt;p&gt;The work is not lost — the branch is intact and the design still holds up. It just is&lt;br&gt;
not the thing I should be spending review capital on right now.&lt;/p&gt;

&lt;p&gt;That framing — &lt;strong&gt;review capacity as a budget you spend&lt;/strong&gt; — was new to me this week.&lt;br&gt;
Maintainer attention is the scarcest resource on the project. Every PR I open spends&lt;br&gt;
some, and if I spend it on something that is not on the critical path, the thing that&lt;br&gt;
is on the critical path waits longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opening the first small one
&lt;/h2&gt;

&lt;p&gt;Right at the end of the week I opened&lt;br&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7647" rel="noopener noreferrer"&gt;#7647&lt;/a&gt;, and it is deliberately&lt;br&gt;
tiny.&lt;/p&gt;

&lt;p&gt;It fixes a bug in the &lt;em&gt;existing&lt;/em&gt; LTI 1.1 code: when a teacher grades a project in an LTI&lt;br&gt;
session, the score was pushed to the LMS before the grade was saved locally. If the&lt;br&gt;
local save then failed validation, the LMS would show a grade that CircuitVerse had&lt;br&gt;
never stored. The gradebook and the source of truth silently disagree.&lt;/p&gt;

&lt;p&gt;It has nothing to do with 1.3. I picked it as the first thing to send precisely because&lt;br&gt;
it is small, self-contained, and improves the integration that already has real users —&lt;br&gt;
and because it demonstrates I can send something a maintainer can review in ten minutes.&lt;/p&gt;

&lt;p&gt;Which, after week 3, felt like the point.&lt;/p&gt;

&lt;p&gt;Next week: a reviewer finds a bug in it that I had looked straight past, and I cut the&lt;br&gt;
remaining work into twenty-four pull requests.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>lti</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Community Bonding: Before the First Line of Project Code</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Sat, 08 Aug 2026 02:01:18 +0000</pubDate>
      <link>https://dev.to/magic-peach/community-bonding-before-the-first-line-of-project-code-5ia</link>
      <guid>https://dev.to/magic-peach/community-bonding-before-the-first-line-of-project-code-5ia</guid>
      <description>&lt;p&gt;Coding starts on 1 June. This post is about the three weeks before that — what I did&lt;br&gt;
with them, and what I thought the project was going to be, which turned out to be only&lt;br&gt;
partly right.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I got here
&lt;/h2&gt;

&lt;p&gt;By the time the accepted projects were announced, I had already sent CircuitVerse about&lt;br&gt;
forty pull requests.&lt;/p&gt;

&lt;p&gt;That number sounds more impressive than it is. A lot of them were typo fixes in&lt;br&gt;
documentation and specs — genuinely trivial changes, the kind you send while you are&lt;br&gt;
still working out where anything lives. Some were closed. Some are still open.&lt;/p&gt;

&lt;p&gt;But they did their job, which was less about the changes themselves than about learning&lt;br&gt;
the shape of the project: how the app is laid out, what the review process feels like,&lt;br&gt;
which parts of the codebase are load-bearing and which are quiet corners nobody has&lt;br&gt;
touched in three years.&lt;/p&gt;

&lt;p&gt;The ones I am actually pleased with came later and were small bug fixes rather than&lt;br&gt;
typos — scoping an assignments index to the current user's groups, removing a duplicate&lt;br&gt;
&lt;code&gt;update!&lt;/code&gt; call, adding &lt;code&gt;includes(:user)&lt;/code&gt; to some notification callbacks that were firing&lt;br&gt;
N+1 queries.&lt;/p&gt;

&lt;p&gt;One of them turned out to be a signpost: a partial unique index on&lt;br&gt;
&lt;code&gt;assignments.lti_consumer_key&lt;/code&gt;, because LTI 1.1 launches look assignments up by that&lt;br&gt;
column and there was nothing enforcing uniqueness. That was the first time I read&lt;br&gt;
CircuitVerse's LMS integration properly, months before I proposed to rewrite it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a proposal into issues
&lt;/h2&gt;

&lt;p&gt;The most useful thing I did during bonding was on 25 May, and it produced no code at&lt;br&gt;
all. I broke the project down into four separate feature issues and filed them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/issues/7405" rel="noopener noreferrer"&gt;#7405&lt;/a&gt; — upgrade the LMS
integration from LTI 1.1 to LTI 1.3&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/issues/7406" rel="noopener noreferrer"&gt;#7406&lt;/a&gt; — roster sync, so a
class list flows from the LMS into a CircuitVerse group&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/issues/7407" rel="noopener noreferrer"&gt;#7407&lt;/a&gt; — grade passback via
Assignment and Grade Services&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/issues/7408" rel="noopener noreferrer"&gt;#7408&lt;/a&gt; — deep linking, so an
instructor can embed specific CircuitVerse content in a course page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A proposal is a document you write for a small audience during a selection process, and&lt;br&gt;
then it mostly sits still. Issues are different. They live in the repository where the&lt;br&gt;
work happens, anyone can comment on them, and they force you to state what you are&lt;br&gt;
actually going to build in terms a maintainer can disagree with &lt;em&gt;before&lt;/em&gt; you have built&lt;br&gt;
anything.&lt;/p&gt;

&lt;p&gt;Writing them also clarified something for me. In the proposal, "LTI 1.3 support" was one&lt;br&gt;
line item. Split into four issues, the dependency structure became obvious: the launch&lt;br&gt;
has to exist before grades can flow back, because grade passback needs the deployment&lt;br&gt;
and resource-link context that a launch establishes. Roster sync and deep linking both&lt;br&gt;
need the same. #7405 is not one of four features, it is the foundation the other three&lt;br&gt;
stand on.&lt;/p&gt;

&lt;p&gt;That ordering shaped everything since.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the code I was proposing to replace
&lt;/h2&gt;

&lt;p&gt;The other substantial piece of bonding was reading CircuitVerse's existing LTI 1.1&lt;br&gt;
integration line by line — the controller, the score submission service, the launch&lt;br&gt;
views, the specs.&lt;/p&gt;

&lt;p&gt;I went in expecting to find it neglected, because 1.1 is the old standard and my project&lt;br&gt;
is the new one. That is not what I found. It is compact and it works, and it is used by&lt;br&gt;
real courses today.&lt;/p&gt;

&lt;p&gt;I did find things I noted for later. Grades were being pushed to the LMS before being&lt;br&gt;
saved locally, so a failed local save could leave the gradebook holding a score&lt;br&gt;
CircuitVerse never stored. A &lt;code&gt;NameError&lt;/code&gt; in a code path that only triggers under&lt;br&gt;
specific conditions. Small gaps, mostly in error handling rather than in the happy path.&lt;/p&gt;

&lt;p&gt;Two of them became my first pull requests of the coding period.&lt;/p&gt;

&lt;p&gt;Reading it also settled a design question I had been vague about in the proposal.&lt;br&gt;
Nothing about my project requires removing 1.1, and plenty of institutions are still on&lt;br&gt;
it. So the plan became strictly additive: 1.3 arrives alongside 1.1, both work, and&lt;br&gt;
nobody's existing setup breaks. "Backward compatible" stopped being a nice phrase in a&lt;br&gt;
proposal and became a constraint I could check every change against.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mergathon
&lt;/h2&gt;

&lt;p&gt;On 26 May, CircuitVerse ran a Mergathon — a coordinated push across all the org's&lt;br&gt;
repositories to deal with the pull request backlog. I spent the day on triage.&lt;/p&gt;

&lt;p&gt;I have permissions to label, close and comment, not to merge, so the work was: read&lt;br&gt;
every open PR, work out whether it was still alive, and close the ones that were not&lt;br&gt;
with an explanation.&lt;/p&gt;

&lt;p&gt;The numbers were bracing. On the Interactive-Book repository I closed around forty-six&lt;br&gt;
stale pull requests, some open since 2019. Thirteen more on the Vue frontend, five on&lt;br&gt;
the blog. On the mobile app there were ten Dependabot PRs all failing for the same&lt;br&gt;
reason — a dependency requiring a newer Flutter SDK than CI was running — so I traced&lt;br&gt;
the root cause once and commented it on all ten. A handful of others were genuinely&lt;br&gt;
green and just needed someone to say so, which I labelled for a maintainer to merge.&lt;/p&gt;

&lt;p&gt;Reading dozens of dead pull requests in one sitting is an unusual way to spend a day and&lt;br&gt;
I learned more from it than from anything else in bonding. Almost none of them died&lt;br&gt;
because the code was wrong. They died from going quiet — the author moved on, the branch&lt;br&gt;
drifted behind master, a maintainer asked a question that was never answered, and then&lt;br&gt;
too much time passed for anyone to want to pick it up.&lt;/p&gt;

&lt;p&gt;The pattern I noticed and did not fully absorb: the bigger a pull request was, the more&lt;br&gt;
likely it was to be in that pile. Small ones either merged or got closed quickly. Large&lt;br&gt;
ones lingered until they were unmergeable.&lt;/p&gt;

&lt;p&gt;I understood that as an observation about &lt;em&gt;other people's&lt;/em&gt; pull requests. It took until&lt;br&gt;
week 3 of the coding period, and having my own large one closed, to understand it was&lt;br&gt;
about mine too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up something to test against
&lt;/h2&gt;

&lt;p&gt;The last piece was practical: getting an LMS I could actually launch from. Reading the&lt;br&gt;
LTI specification tells you what the protocol says. Watching a real Canvas instance&lt;br&gt;
refuse your launch for reasons the spec does not mention teaches you a different set of&lt;br&gt;
things.&lt;/p&gt;

&lt;p&gt;This took longer than expected, which I gather is normal. LTI is a protocol where two&lt;br&gt;
servers have to agree about URLs, keys and identifiers before anything works at all, and&lt;br&gt;
every mismatch surfaces as the same unhelpful failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected to be hard
&lt;/h2&gt;

&lt;p&gt;Going into 1 June, I thought the difficulty of this project was the protocol —&lt;br&gt;
signatures, key rotation, the security model, getting the cryptography right.&lt;/p&gt;

&lt;p&gt;Some of that has been hard. But the protocol is a written specification with a right&lt;br&gt;
answer; if you read it carefully you converge on it.&lt;/p&gt;

&lt;p&gt;The part I did not budget for is the one this whole first stretch has actually turned&lt;br&gt;
on: getting the work into a shape somebody else can be confident about. That is not in&lt;br&gt;
any specification, and it is not something I understood until I had done it wrong.&lt;/p&gt;

&lt;p&gt;Next: week 1, and actually writing some code.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>programming</category>
      <category>lti</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Week 5: Identity Is Not an Email Address</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:15:14 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-5-identity-is-not-an-email-address-4ie9</link>
      <guid>https://dev.to/magic-peach/week-5-identity-is-not-an-email-address-4ie9</guid>
      <description>&lt;p&gt;This week produced the single most important line of the project so far, and a decision&lt;br&gt;
I would reverse four days later. It also took a detour into a completely different&lt;br&gt;
feature, which turned out to be a better use of time than it sounds.&lt;/p&gt;
&lt;h2&gt;
  
  
  The claim you must not trust
&lt;/h2&gt;

&lt;p&gt;The commit is&lt;br&gt;
&lt;code&gt;fix(lti): key 1.3 identity on validated sub, not the email claim&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the problem. When a launch arrives, the &lt;code&gt;id_token&lt;/code&gt; contains claims about the&lt;br&gt;
user — a subject identifier (&lt;code&gt;sub&lt;/code&gt;), usually a name, and often an email address. The&lt;br&gt;
natural way to sign somebody in is to take that email and look them up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;email: &lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what my POC did, and it is wrong in a way that took me a while to see, because&lt;br&gt;
it &lt;em&gt;works perfectly&lt;/em&gt; in every test you would think to write.&lt;/p&gt;

&lt;p&gt;The token's signature proves the platform sent it. It does not prove the platform&lt;br&gt;
verified anything inside it. The email claim is whatever the LMS has in its user record&lt;br&gt;
— self-asserted, frequently unverified, sometimes editable by the user in their own&lt;br&gt;
profile settings.&lt;/p&gt;

&lt;p&gt;So if a CircuitVerse account exists with the email &lt;code&gt;maintainer@circuitverse.org&lt;/code&gt;, and&lt;br&gt;
somebody can get an LMS to emit a launch with that email claim, matching on email hands&lt;br&gt;
them that account. The token is valid. The signature checks out. And they are signed in&lt;br&gt;
as somebody else.&lt;/p&gt;

&lt;p&gt;The fix is to identify people by the &lt;code&gt;sub&lt;/code&gt; claim, which is the platform's own stable&lt;br&gt;
identifier for that user and is not user-editable, scoped to the deployment it came&lt;br&gt;
from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_or_create_by!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;provider: &lt;/span&gt;&lt;span class="s2"&gt;"lti"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;uid: &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sub'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scoping matters as much as the choice of claim. Two different institutions can both&lt;br&gt;
have a user with &lt;code&gt;sub&lt;/code&gt; of &lt;code&gt;12345&lt;/code&gt;. Without the deployment prefix, those two people&lt;br&gt;
collide into one CircuitVerse account.&lt;/p&gt;

&lt;p&gt;Email still gets used — when provisioning a brand new account, we have to put something&lt;br&gt;
in the email column. But it is used as an attribute of a user we have already&lt;br&gt;
identified, never as the thing that identifies them. That distinction is the whole&lt;br&gt;
lesson: &lt;strong&gt;authentication data and profile data are different, even when they arrive in&lt;br&gt;
the same signed envelope.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The companion commit, &lt;code&gt;fix(lti): return 401 for tokens missing required claims&lt;/code&gt;, comes&lt;br&gt;
from the same place. If &lt;code&gt;sub&lt;/code&gt; is missing, there is no identity to key on, and the right&lt;br&gt;
answer is to reject the launch rather than fall back to something more convenient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cookie I weakened
&lt;/h2&gt;

&lt;p&gt;The other commit from this week is&lt;br&gt;
&lt;code&gt;Add Canvas placements to tool config and SameSite=None session cookie&lt;/code&gt;, and I want to&lt;br&gt;
write about it honestly because I undid half of it the following week.&lt;/p&gt;

&lt;p&gt;The problem is real. When the platform POSTs the &lt;code&gt;id_token&lt;/code&gt; back to CircuitVerse, that&lt;br&gt;
is a &lt;strong&gt;cross-site POST&lt;/strong&gt; — the request originates from Canvas's domain and lands on&lt;br&gt;
ours. Browsers do not send &lt;code&gt;SameSite=Lax&lt;/code&gt; cookies on cross-site POSTs. That is the&lt;br&gt;
entire point of &lt;code&gt;SameSite&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had stored the login state in the session. So when the launch came back, the session&lt;br&gt;
cookie was not sent, the session was empty, and the launch failed with no state to&lt;br&gt;
check against.&lt;/p&gt;

&lt;p&gt;The fix I reached for was to set &lt;code&gt;SameSite=None&lt;/code&gt; on the session cookie, which makes the&lt;br&gt;
browser send it on cross-site requests. It worked immediately.&lt;/p&gt;

&lt;p&gt;It is also a change to how &lt;em&gt;every&lt;/em&gt; cookie in the application behaves, made to serve one&lt;br&gt;
endpoint. Every page on CircuitVerse would have a session cookie that browsers are&lt;br&gt;
willing to attach to cross-site requests, weakening a default that exists specifically&lt;br&gt;
to blunt CSRF — for a feature that is off by default and used by a small fraction of&lt;br&gt;
users.&lt;/p&gt;

&lt;p&gt;I did not see it that way when I wrote it. I saw a blocked feature and a config flag&lt;br&gt;
that unblocked it. Seeing it required asking a different question: not "does this make&lt;br&gt;
the launch work" but "what else does this change, for people not using this feature at&lt;br&gt;
all?" More on how I fixed it next week.&lt;/p&gt;

&lt;p&gt;The other half of the commit has aged better: Canvas placements in the tool&lt;br&gt;
configuration document. Placements are how a tool declares where it wants to appear in&lt;br&gt;
the LMS — course navigation, assignment selection. It is the difference between a tool&lt;br&gt;
an admin can register and a tool an instructor can actually find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detour: subgroups
&lt;/h2&gt;

&lt;p&gt;Midweek I switched to something unrelated: subgroups within a group, so an instructor&lt;br&gt;
can split a class into smaller teams. Commits&lt;br&gt;
&lt;code&gt;feat(groups): add self-referential subgroups data layer&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;feat(groups): subgroup management UI, controller and API scoping&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The design decision worth recording: a subgroup is just a &lt;code&gt;Group&lt;/code&gt; with a&lt;br&gt;
&lt;code&gt;parent_group_id&lt;/code&gt;. That is it. No new model, no parallel hierarchy.&lt;/p&gt;

&lt;p&gt;The alternative was a dedicated &lt;code&gt;Subgroup&lt;/code&gt; model. It looks cleaner on a diagram and it&lt;br&gt;
is much worse in practice, because assignments, membership, grades, notifications and&lt;br&gt;
authorization policies are all written against &lt;code&gt;Group&lt;/code&gt;. A new model means every one of&lt;br&gt;
those systems needs to learn about a second kind of container, and every one of them is&lt;br&gt;
a place to forget. A self-referential association means all of that machinery works&lt;br&gt;
unchanged, and the entire feature is a nullable foreign key plus scoping.&lt;/p&gt;

&lt;p&gt;Why work on this at all mid-project? Partly because it had been asked for. But the&lt;br&gt;
honest reason is that I was waiting on review for the LTI work, and I had learned the&lt;br&gt;
week before that pushing on a branch nobody has agreed to review does not move anything&lt;br&gt;
forward. Having a second track meant review latency stopped being dead time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself
&lt;/h2&gt;

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The signature does not vouch for the contents.&lt;/strong&gt; A signed token proves who sent it,&lt;br&gt;
not that what it says is true. Everything inside still needs to be evaluated on whether&lt;br&gt;
the sender had any business asserting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"It works" is where you start asking questions, not where you stop.&lt;/strong&gt; The&lt;br&gt;
&lt;code&gt;SameSite=None&lt;/code&gt; change worked on the first try. Working was exactly what stopped me&lt;br&gt;
looking at it.&lt;/p&gt;

&lt;p&gt;Next week: undoing the cookie change properly, and two pull requests getting closed on&lt;br&gt;
the same day.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>opensource</category>
      <category>api</category>
    </item>
    <item>
      <title>Week 4: Rebuilding From the Spec (and Dropping the Gem)</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:11:20 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-4-rebuilding-from-the-spec-and-dropping-the-gem-1in0</link>
      <guid>https://dev.to/magic-peach/week-4-rebuilding-from-the-spec-and-dropping-the-gem-1in0</guid>
      <description>&lt;p&gt;After a week with nothing to show, this one produced a lot: the LTI 1.3 infrastructure&lt;br&gt;
rebuilt from the specification rather than from other people's example code, a&lt;br&gt;
dependency added and then removed again, and the three security checks that make a 1.3&lt;br&gt;
launch trustworthy written as three separate, named things instead of one blob.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting with the gem
&lt;/h2&gt;

&lt;p&gt;I had opened &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7510" rel="noopener noreferrer"&gt;#7510&lt;/a&gt; the&lt;br&gt;
previous week to add &lt;code&gt;lti-advantage&lt;/code&gt; 0.1.0, and I had a separate issue&lt;br&gt;
(&lt;a href="https://github.com/CircuitVerse/CircuitVerse/issues/7509" rel="noopener noreferrer"&gt;#7509&lt;/a&gt;) tracking it. My&lt;br&gt;
assumption was the ordinary one: this is a well-specified protocol, somebody has&lt;br&gt;
written the gem, I should use the gem rather than hand-rolling security code.&lt;/p&gt;

&lt;p&gt;That assumption is usually right. It was not right here, and the version number was the&lt;br&gt;
clue I ignored. &lt;code&gt;0.1.0&lt;/code&gt; is not a version that has been through much contact with&lt;br&gt;
production.&lt;/p&gt;

&lt;p&gt;When I started wiring the rebuilt launch against it, the shape of the problem became&lt;br&gt;
clear. The gem gave me some convenience wrappers around things &lt;code&gt;ruby-jwt&lt;/code&gt; already does&lt;br&gt;
well, and did not give me the parts that are actually hard: resolving a platform's&lt;br&gt;
public key by &lt;code&gt;kid&lt;/code&gt; from its JWKS endpoint, handling key rotation, binding a token to a&lt;br&gt;
specific deployment. Those I was going to write regardless.&lt;/p&gt;

&lt;p&gt;So I was adding a dependency, in the authentication path, that I would still have to&lt;br&gt;
write the security-critical code around. That is the worst of both: a supply-chain&lt;br&gt;
surface without the benefit.&lt;/p&gt;

&lt;p&gt;By the end of the week I had reversed it. Two commits tell that story —&lt;br&gt;
&lt;code&gt;build(deps): restore ims-lti for the LTI 1.1 launch path&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;build(deps): drop unused lti-advantage gem&lt;/code&gt;. CircuitVerse keeps &lt;code&gt;ims-lti&lt;/code&gt; for the&lt;br&gt;
existing 1.1 path, which still has real users, and 1.3 gets built directly on&lt;br&gt;
&lt;code&gt;ruby-jwt&lt;/code&gt;, which is mature and which I understand.&lt;/p&gt;

&lt;p&gt;The lesson I want to keep: "there's a gem for it" is a hypothesis, not a conclusion.&lt;br&gt;
Check what it actually does before you build on it, especially in an auth path. The&lt;br&gt;
cost of finding out late is that you have already designed around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three checks
&lt;/h2&gt;

&lt;p&gt;The main work of the week was the commit&lt;br&gt;
&lt;code&gt;fix(lti): enforce state, nonce, and deployment binding on 1.3 launch&lt;/code&gt;. In the POC, all&lt;br&gt;
of this had been one function that either returned a user or didn't. Pulling the three&lt;br&gt;
defences apart and naming them individually changed how I thought about each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State.&lt;/strong&gt; The &lt;code&gt;state&lt;/code&gt; parameter is a value the tool generates when it starts the login&lt;br&gt;
and checks when the launch comes back. Without it, anyone can POST a token at your&lt;br&gt;
launch endpoint and you have no idea whether you ever asked for that login. It is the&lt;br&gt;
CSRF defence for the handshake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nonce.&lt;/strong&gt; The tool generates a nonce, sends it in the authorization request, and the&lt;br&gt;
platform copies it into the &lt;code&gt;id_token&lt;/code&gt; it returns. When the token comes back, the nonce&lt;br&gt;
must match the one issued. This ties the token to &lt;em&gt;this&lt;/em&gt; login attempt. Without it a&lt;br&gt;
token captured once could be presented again later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment binding.&lt;/strong&gt; This is the one I had missed entirely in the POC, and the one&lt;br&gt;
reading the spec properly surfaced. A platform is identified by an issuer, a client ID,&lt;br&gt;
and a deployment ID. Every one of those platforms signs its own valid tokens. So a&lt;br&gt;
token can be perfectly valid — correct signature, unexpired, well-formed — and still be&lt;br&gt;
from a completely different institution's Canvas than the one that started this login.&lt;br&gt;
The token has to be bound to the deployment the login was initiated against, or a valid&lt;br&gt;
token from platform B can be presented in a session opened for platform A.&lt;/p&gt;

&lt;p&gt;It is the kind of hole you do not find by testing, because every test you write by hand&lt;br&gt;
uses one platform. You find it by reading the spec and asking what each field is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity, and a bug I created for myself
&lt;/h2&gt;

&lt;p&gt;Two smaller commits this week were about actually signing the user in, and both came&lt;br&gt;
from things breaking.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fix(lti): confirm LTI 1.3 provisioned users so sign_in succeeds&lt;/code&gt; — CircuitVerse uses&lt;br&gt;
Devise with the &lt;code&gt;:confirmable&lt;/code&gt; module, so a newly created user has to have a&lt;br&gt;
confirmation timestamp before they can be signed in. When CircuitVerse provisions an&lt;br&gt;
account from a launch, the platform has already authenticated that person; there is no&lt;br&gt;
email round-trip to wait for. So provisioning sets &lt;code&gt;confirmed_at&lt;/code&gt; at creation. Obvious&lt;br&gt;
in hindsight, and it took a while to see, because the failure looked like the sign-in&lt;br&gt;
silently doing nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fix(spec): stub JWKS HTTP request in LTI 1.3 launch tests&lt;/code&gt; — the launch fetches the&lt;br&gt;
platform's public keys over HTTP. My tests were really trying to make that request.&lt;br&gt;
Stubbing it made the suite fast and deterministic, and it forced me to be explicit&lt;br&gt;
about what the JWKS response actually looks like, which paid off later when I came to&lt;br&gt;
handle key rotation.&lt;/p&gt;

&lt;p&gt;There was also a housekeeping commit,&lt;br&gt;
&lt;code&gt;fix(lti): add missing schema migration and resolve all rubocop offenses&lt;/code&gt;. Not&lt;br&gt;
interesting, except as a reminder that "it works" and "it is ready for someone else to&lt;br&gt;
look at" are different states.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing tests against forgeries
&lt;/h2&gt;

&lt;p&gt;The other shift this week was in how I write tests for this. My instinct had been to&lt;br&gt;
test the happy path thoroughly and add a couple of failure cases.&lt;/p&gt;

&lt;p&gt;For authentication code that is the wrong ratio. The happy path is the case an attacker&lt;br&gt;
does not care about. What matters is every way a token can be wrong: signed with the&lt;br&gt;
wrong key, signed with no key at all, signed with a symmetric algorithm when we expect&lt;br&gt;
an asymmetric one, expired, wrong issuer, wrong audience, right issuer but wrong&lt;br&gt;
deployment, missing the subject claim, replayed nonce.&lt;/p&gt;

&lt;p&gt;Each of those is a test that fails loudly if I ever weaken the validator. That framing —&lt;br&gt;
the test suite as a set of attacks the code has to survive, not a set of features it has&lt;br&gt;
to have — is the thing I have found most useful to internalise, and it is what made the&lt;br&gt;
validator work later in the project reviewable in isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves the week
&lt;/h2&gt;

&lt;p&gt;The protocol layer exists again, built from the spec, with the three defences separated&lt;br&gt;
and named, no unnecessary dependency in the auth path, and a test suite that mostly&lt;br&gt;
consists of things going wrong.&lt;/p&gt;

&lt;p&gt;What it does not have yet is a shape a reviewer can accept. It is still one branch with&lt;br&gt;
everything on it. That problem is still ahead of me, and it is the one that closed my&lt;br&gt;
POC.&lt;/p&gt;

&lt;p&gt;Next week: the email address a platform sends you is not proof of anything, and I&lt;br&gt;
weaken a cookie in a way I will have to undo.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>opensource</category>
      <category>api</category>
    </item>
  </channel>
</rss>
