Skip to content

feat(jose): add compact JWE encryption and decryption utilities#123

Merged
halvaradop merged 5 commits intomasterfrom
feat/introduce-session-option
Mar 21, 2026
Merged

feat(jose): add compact JWE encryption and decryption utilities#123
halvaradop merged 5 commits intomasterfrom
feat/introduce-session-option

Conversation

@halvaradop
Copy link
Member

@halvaradop halvaradop commented Mar 20, 2026

Description

This pull request introduces compact JWE utilities to the @aura-stack/jose package, providing a clear and focused API for encrypting and decrypting plain text content.

Previously, JWE utilities accepted and returned raw strings, which made the distinction between encrypted JWT payloads and generic encrypted content unclear. With this update:

  • encryptJWE and decryptJWE are now strictly typed to work with JWTPayload objects.
  • A new compact JWE API is introduced to handle plain text encryption and decryption use cases.

The new compact utilities (createCompactJWE) allow developers to explicitly encrypt and decrypt string-based payloads, improving clarity and separation of concerns between JWT-based flows and generic encryption.

Additionally:

  • encodeJWT and decodeJWT now internally rely on the compact JWE implementation for encryption flows.
  • Protected header validation has been strengthened for both JWS and JWE operations, improving security and reducing potential attack vectors.
  • Test coverage has been increased to ensure reliability of the new functionality.

Usage

import { createCompactJWE, createJWE } from "@aura-stack/jose"

const compactJWE = createCompactJWE("secret")

compactJWE.compactEncryptJWE("payload", {
  alg: "dir",
  enc: "A256GCM",
})

compactJWE.decryptCompactJWE("token", {
  keyManagementAlgorithms: ["dir"],
  contentEncryptionAlgorithms: ["A256GCM"],
})

const jwe = createJWE<{ name: string }>("secret")

jwe.encryptJWE(
  { sub: "1234567890", name: "John Doe" },
  {
    alg: "dir",
    enc: "A256GCM",
  }
)

const { name, sub } = await jwe.decryptJWE("token", {
  keyManagementAlgorithms: ["dir"],
  contentEncryptionAlgorithms: ["A256GCM"],
})

Changes

- export const encryptJWE = async (payload: string, secret: SecretInput, options?: EncryptOptions) => {
+ export const encryptJWE = async <Payload extends JWTPayload>(
+   payload: TypedJWTPayload<Partial<Payload>>,
+   secret: SecretInput,
+   options?: JWEHeaderParameters
+ ): Promise<string> => {

- export const decryptJWE = async (token: string, secret: SecretInput, options?: JWTDecryptOptions) => {
+ export const decryptJWE = async <Payload extends JWTPayload>(
+   token: string,
+   secret: SecretInput,
+   options?: JWTDecryptOptions
+ ): Promise<TypedJWTPayload<Payload>> => {

- export const createJWE = (secret: SecretInput) => {
+ export const createJWE = <Payload extends JWTPayload>(secret: SecretInput) => {

@vercel
Copy link

vercel bot commented Mar 20, 2026

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

Project Deployment Actions Updated (UTC)
auth-nextjs-demo Error Error Mar 22, 2026 0:49am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
auth Skipped Skipped Mar 22, 2026 0:49am

@coderabbitai
Copy link

coderabbitai bot commented Mar 20, 2026

📝 Walkthrough

Walkthrough

Renames JOSE key/option fields from { jws, jwe }{ sign, encrypt }; makes JWS/JWE APIs generic over User/JWTPayload; adds compact JWE helpers; changes function signatures and option types across packages/core and packages/jose; updates changelogs and tests.

Changes

Cohort / File(s) Summary
Core JOSE surface
packages/core/src/jose.ts, packages/core/CHANGELOG.md
Renamed derived-key and encode/decode option shapes to sign/encrypt; updated factory helpers and exported method signatures (decodeJWT, encodeJWT, signJWS, verifyJWS, encryptJWE, decryptJWE); removed module-level verification option constants; changelog updated.
JWE implementation & API
packages/jose/src/encrypt.ts, packages/jose/src/index.ts, packages/jose/src/secret.ts, packages/jose/CHANGELOG.md
Made JWE APIs generic over JWTPayload (no raw string payloads for main JWE functions); added compact JWE helpers (compactEncryptJWE, decryptCompactJWE, createCompactJWE); merged default protected headers with provided options; adjusted secret derivation keys to sign/encrypt; unified error wrapping and option shapes.
JWS implementation & API
packages/jose/src/sign.ts
Added JWTHeaderParameters and optional header options to signJWS; merged header options into protected header; made iat/jti honor payload values; verify wrapper enforces algorithms/typ via merged options; exported header types.
Public API & typing surface
packages/jose/src/index.ts, packages/jose/test/types.test-d.ts
Changed exported types and function signatures: DerivedKeyInput{ sign, encrypt }; added EncodeJWTOptions, DecodeJWTOptions, CreateJWTOptions; encodeJWT/decodeJWT now accept new option shapes; added export type * re-exports. Updated type tests to match new signatures.
Tests
packages/jose/test/index.test.ts
Updated tests to use structured payloads for JWE, added compact-JWE tests, expanded JWS claim/header coverage, adjusted createJWT option shape tests, and updated type assertions.
Core secure usage
packages/core/src/secure.ts
Removed explicit jwtVerificationOptions usage; calls to verifyJWS now omit the second argument and rely on default behavior.
Misc apps
apps/astro/astro.config.mjs, apps/tanstack-start/src/lib/auth-server.ts
Removed a // @ts-expect-error`` comment in Astro config; made redirect calls tolerant to null API responses via optional chaining.
sequenceDiagram
    autonumber
    actor Client
    participant JWT as Encode/Decode Module
    participant KDF as KeyDerivation
    participant JWS as signJWS/verifyJWS
    participant JWE as compactEncryptJWE/decryptCompactJWE

    Client->>JWT: encodeJWT(payload, options)
    JWT->>KDF: derive sign & encrypt secrets (concurrently)
    KDF-->>JWT: derived keys
    JWT->>JWS: signJWS(payload, options?.sign)
    JWS-->>JWT: signed JWT
    JWT->>JWE: compactEncryptJWE(signedJWT, options?.encrypt)
    JWE-->>Client: return compact JWE
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

feature, breaking changes

Poem

🐰 I hopped through keys and header trees,

I swapped some names with nimble ease,
Compact tokens now roll tight and neat,
Signed, encrypted — a tidy treat,
🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: adding compact JWE encryption and decryption utilities to the jose package.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/introduce-session-option

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

❤️ Share

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/CHANGELOG.md`:
- Around line 11-12: Add a changelog entry under the existing "Unreleased" ->
"Changed" subsection documenting the new session option added to createAuth:
briefly describe that createAuth now accepts a session option (what it
does/affects and any default behavior or breaking changes), reference the
function name createAuth and the "session" option explicitly, and remove the
empty subsection header if you instead move the content elsewhere so the
changelog is not left empty.

In `@packages/jose/CHANGELOG.md`:
- Around line 11-12: The changelog’s "### Added" subsection is empty; update the
Unreleased changelog by adding a concise entry under the "### Added" header
describing the new "JWT headers support" feature (e.g., "Add JWT headers support
— allow custom and standard JWT header fields to be set and verified"),
optionally referencing the PR or commit ID and author for traceability so
consumers can see what was added.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e2e8b3b3-bf24-48f8-9177-2e367dfd5880

📥 Commits

Reviewing files that changed from the base of the PR and between 9cd1b1b and ca6bc01.

📒 Files selected for processing (2)
  • packages/core/CHANGELOG.md
  • packages/jose/CHANGELOG.md

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/jose.ts`:
- Around line 103-107: The decodeJWTOptions object is wired to the wrong branch:
swap the assignments so sign receives jwtVerificationOptions and encrypt only
contains JWE-specific constraints; update decodeJWTOptions (used by decodeJWT())
to set sign: jwtVerificationOptions and move any HS256/ signature allow-list out
of encrypt into sign, leaving only JWE-related constraints under encrypt.
- Around line 56-60: The CSRF key derivation is using the wrong context: change
the third createDeriveKey call that produces derivedCsrfTokenKey to use the
CSRF/JWS-specific label instead of "encryption" (i.e., call
createDeriveKey(secret, salt, "jws") or the CSRF label used elsewhere) so
derivedCsrfTokenKey is derived with a distinct context; update the array where
derivedSigningKey, derivedEncryptionKey, derivedCsrfTokenKey are created to use
that CSRF/JWS label.

In `@packages/jose/src/encrypt.ts`:
- Around line 65-73: compactEncryptJWE is dropping nested-JWT header fields
because setProtectedHeader currently only sets alg and enc; when wrapping a
signed JWT you must preserve or add the content type. Update compactEncryptJWE
(the CompactEncrypt(...).setProtectedHeader(...) call) to include cty: "JWT"
(and preserve any provided typ/cty from options) when the payload is a JWT or
when options doesn't already specify cty, so the outer JWE retains the
nested-JWT header expected by validators.
- Around line 118-126: Change the parameter type for decryptCompactJWE from
JWTDecryptOptions to DecryptOptions: update the function signature of
decryptCompactJWE to accept options?: DecryptOptions (it delegates to
compactDecrypt which only accepts DecryptOptions), and then update the type for
DecodeJWTOptions.encrypt in index.ts to use DecryptOptions instead of
JWTDecryptOptions so the propagated API matches compactDecrypt; ensure any
imports/types are adjusted accordingly (reference: decryptCompactJWE and
compactDecrypt, and DecodeJWTOptions.encrypt).

In `@packages/jose/src/index.ts`:
- Around line 71-72: The change makes encodeJWT encrypt the raw JWS string
(signed via signJWS) which breaks backwards compatibility because previous
tokens encrypted a JSON claims set (e.g., { payload: signedJws }); to fix,
restore the prior wire format or add a dual-decode path: when creating tokens
with compactEncryptJWE keep wrapping the signed JWS in an envelope object (e.g.,
{ payload: signedJws }) so existing decryptors still get a claims set, or update
compactDecryptJWE/decodeJWT to detect and accept both the legacy JSON envelope
and a raw JWS string (handle string vs object) so sessions minted before rollout
remain valid; locate and change the logic in encodeJWT (where signJWS and
compactEncryptJWE are called) and in the complementary decode/verification code
that calls compactDecryptJWE to implement the compatibility branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e98aa884-b8cf-4961-a7d0-2950a9366b40

📥 Commits

Reviewing files that changed from the base of the PR and between ca6bc01 and 6872a65.

📒 Files selected for processing (9)
  • packages/core/CHANGELOG.md
  • packages/core/src/jose.ts
  • packages/jose/CHANGELOG.md
  • packages/jose/src/encrypt.ts
  • packages/jose/src/index.ts
  • packages/jose/src/secret.ts
  • packages/jose/src/sign.ts
  • packages/jose/test/index.test.ts
  • packages/jose/test/types.test-d.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/core/CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/jose/CHANGELOG.md

@halvaradop halvaradop changed the title feat(core,jose): add session option in createAuth and JWT headers feat(core): add compact JWE encryption and decryption utilities Mar 21, 2026
@halvaradop halvaradop closed this Mar 21, 2026
@halvaradop halvaradop deleted the feat/introduce-session-option branch March 21, 2026 02:10
@halvaradop halvaradop restored the feat/introduce-session-option branch March 21, 2026 02:10
@halvaradop halvaradop reopened this Mar 21, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
apps/tanstack-start/src/lib/auth-server.ts (1)

44-50: ⚠️ Potential issue | 🟠 Major

Redirecting to undefined href when sign-in fails.

When api.signIn() throws an error, .catch() returns null, making response?.signInURL evaluate to undefined. Calling redirect({ href: undefined }) will result in invalid redirect behavior—either a runtime error or navigation to an unintended location.

Unlike the signOutFn case (where headers are optional and redirect to "/" still works), the signInFn requires a valid destination URL for the redirect to function correctly.

🛠️ Proposed fix: Handle the error case explicitly
         .catch((error) => {
             console.error("[error:server] signIn", error)
             return null
         })
+    if (!response) {
+        throw redirect({ to: "/" })
+    }
     throw redirect({
-        href: response?.signInURL,
+        href: response.signInURL,
     })

Alternatively, redirect to a dedicated error page:

+    if (!response) {
+        throw redirect({ to: "/auth/error" })
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/tanstack-start/src/lib/auth-server.ts` around lines 44 - 50, The current
sign-in flow calls api.signIn() and then unconditionally throws redirect({ href:
response?.signInURL }), which can pass undefined when api.signIn() errors;
update the sign-in handler (the api.signIn() call, the response variable and the
redirect usage) to explicitly handle the null/error case: in the .catch() return
pathway log the error and set a fallback destination (e.g., "/" or a dedicated
error page) or throw a redirect to that fallback only if response?.signInURL is
falsy, otherwise redirect to response.signInURL; ensure you do not call redirect
with an undefined href and keep error logging for diagnostics.
🧹 Nitpick comments (1)
packages/jose/test/index.test.ts (1)

370-390: Remove duplicate test block.

This createJWT describe block duplicates tests already present in the JWTs describe block:

  • Lines 371-384 duplicate lines 265-278 ("create a signed and encrypted JWT using createJWT with separate JWS and JWE secrets")
  • Lines 386-389 duplicate lines 260-263 ("createJWT with invalid secret")
♻️ Suggested fix
-describe("createJWT", () => {
-    test("createJWT with separate JWS and JWE secrets", async () => {
-        const secret = getRandomBytes(32)
-        const derivedSigningKey = await createDeriveKey(secret, "salt", "signing")
-        const derivedEncryptionKey = await createDeriveKey(secret, "salt", "encryption")
-
-        const { encodeJWT, decodeJWT } = createJWT({ sign: derivedSigningKey, encrypt: derivedEncryptionKey })
-
-        const jwt = await encodeJWT(payload)
-        expect(jwt).toBeDefined()
-        const decodedPayload = await decodeJWT(jwt)
-        expect(decodedPayload.sub).toBe(payload.sub)
-        expect(decodedPayload.name).toBe(payload.name)
-        expect(decodedPayload.email).toBe(payload.email)
-    })
-
-    test("createJWT with invalid secret", async () => {
-        const { encodeJWT } = createJWT("short")
-        await expect(encodeJWT(payload)).rejects.toThrow("Secret string must be at least 32 bytes long")
-    })
-})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/jose/test/index.test.ts` around lines 370 - 390, The
describe("createJWT") block duplicates tests already present in the "JWTs"
suite; remove the duplicate describe block (including the tests that use
createDeriveKey, createJWT, encodeJWT, decodeJWT and the invalid-secret test) so
only the original tests in the "JWTs" describe remain; ensure no other unique
tests are lost and that imports/helpers (createDeriveKey, createJWT, payload)
remain referenced only where needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/jose/src/index.ts`:
- Around line 39-42: The DecodeJWTOptions type declares decrypt as
JWTDecryptOptions but decryptCompactJWE (which calls compactDecrypt) expects
DecryptOptions; update the DecodeJWTOptions interface to use DecryptOptions for
the decrypt property (replace JWTDecryptOptions with DecryptOptions) so the API
accurately reflects what decryptCompactJWE/compactDecrypt will accept and avoid
exposing JWT-specific verification fields.

---

Outside diff comments:
In `@apps/tanstack-start/src/lib/auth-server.ts`:
- Around line 44-50: The current sign-in flow calls api.signIn() and then
unconditionally throws redirect({ href: response?.signInURL }), which can pass
undefined when api.signIn() errors; update the sign-in handler (the api.signIn()
call, the response variable and the redirect usage) to explicitly handle the
null/error case: in the .catch() return pathway log the error and set a fallback
destination (e.g., "/" or a dedicated error page) or throw a redirect to that
fallback only if response?.signInURL is falsy, otherwise redirect to
response.signInURL; ensure you do not call redirect with an undefined href and
keep error logging for diagnostics.

---

Nitpick comments:
In `@packages/jose/test/index.test.ts`:
- Around line 370-390: The describe("createJWT") block duplicates tests already
present in the "JWTs" suite; remove the duplicate describe block (including the
tests that use createDeriveKey, createJWT, encodeJWT, decodeJWT and the
invalid-secret test) so only the original tests in the "JWTs" describe remain;
ensure no other unique tests are lost and that imports/helpers (createDeriveKey,
createJWT, payload) remain referenced only where needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e65f28d9-824f-459c-b179-501fa19e96d5

📥 Commits

Reviewing files that changed from the base of the PR and between 6872a65 and 217c892.

📒 Files selected for processing (8)
  • apps/astro/astro.config.mjs
  • apps/tanstack-start/src/lib/auth-server.ts
  • packages/core/src/jose.ts
  • packages/core/src/secure.ts
  • packages/jose/src/encrypt.ts
  • packages/jose/src/index.ts
  • packages/jose/src/sign.ts
  • packages/jose/test/index.test.ts
💤 Files with no reviewable changes (1)
  • apps/astro/astro.config.mjs
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/jose/src/sign.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/jose/src/index.ts (1)

76-77: ⚠️ Potential issue | 🟠 Major

Breaking change: JWT wire format has changed.

The encoding now directly encrypts the signed JWS string via compactEncryptJWE(signed, ...) instead of wrapping it in a JSON envelope (e.g., { payload: signedJws }). While this aligns with RFC 7519 Section 5.2 for nested JWTs, it's a breaking change—existing tokens minted with the previous format will fail to decode.

Ensure this is documented as a breaking change, or consider implementing a migration path that can detect and handle both formats during a transition period.

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

In `@packages/jose/src/index.ts` around lines 76 - 77, The output wire format
changed because encode now calls compactEncryptJWE(signed, ...) with the raw
signed JWS from signJWS, breaking consumers expecting a JSON envelope (e.g., {
payload: signedJws }); fix by either restoring the previous envelope when
encrypting (wrap the signed value into { payload: signed } before calling
compactEncryptJWE in the function that currently calls signJWS ->
compactEncryptJWE) or implement backward-compatibility in the decrypt/verify
path (in the JWE decrypt/verify function, after decrypting check if the
plaintext is JSON with a payload field and extract that, otherwise treat
plaintext as a raw signed JWS) and add a short breaking-change note in the
package docs mentioning the new default and the migration support.
🧹 Nitpick comments (1)
packages/jose/src/encrypt.ts (1)

151-173: Minor: inconsistent parameter naming in factory methods.

On line 157, the decryptJWE method accepts payload: string, but semantically this is a token to decrypt, not a payload. Similarly, line 171 uses payload for decryptCompactJWE. Consider renaming to token for consistency with the standalone functions.

♻️ Suggested fix
-        decryptJWE: <Decrypted extends JWTPayload = Payload>(payload: string, options?: JWTDecryptOptions) =>
-            decryptJWE<Decrypted>(payload, secret, options),
+        decryptJWE: <Decrypted extends JWTPayload = Payload>(token: string, options?: JWTDecryptOptions) =>
+            decryptJWE<Decrypted>(token, secret, options),
-        decryptCompactJWE: (payload: string, options?: DecryptOptions) => decryptCompactJWE(payload, secret, options),
+        decryptCompactJWE: (token: string, options?: DecryptOptions) => decryptCompactJWE(token, secret, options),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/jose/src/encrypt.ts` around lines 151 - 173, Rename the parameter
`payload` to `token` in the `decryptJWE` method inside the `createJWE` function
and in the `decryptCompactJWE` method inside the `createCompactJWE` function for
semantic consistency. This aligns the parameter names with their purpose,
matching the standalone functions they wrap.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/jose/src/index.ts`:
- Around line 76-77: The output wire format changed because encode now calls
compactEncryptJWE(signed, ...) with the raw signed JWS from signJWS, breaking
consumers expecting a JSON envelope (e.g., { payload: signedJws }); fix by
either restoring the previous envelope when encrypting (wrap the signed value
into { payload: signed } before calling compactEncryptJWE in the function that
currently calls signJWS -> compactEncryptJWE) or implement
backward-compatibility in the decrypt/verify path (in the JWE decrypt/verify
function, after decrypting check if the plaintext is JSON with a payload field
and extract that, otherwise treat plaintext as a raw signed JWS) and add a short
breaking-change note in the package docs mentioning the new default and the
migration support.

---

Nitpick comments:
In `@packages/jose/src/encrypt.ts`:
- Around line 151-173: Rename the parameter `payload` to `token` in the
`decryptJWE` method inside the `createJWE` function and in the
`decryptCompactJWE` method inside the `createCompactJWE` function for semantic
consistency. This aligns the parameter names with their purpose, matching the
standalone functions they wrap.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ea367f5a-6ca5-41ff-af78-d0b54c8bfddb

📥 Commits

Reviewing files that changed from the base of the PR and between 217c892 and 2444793.

📒 Files selected for processing (2)
  • packages/jose/src/encrypt.ts
  • packages/jose/src/index.ts

@halvaradop halvaradop changed the title feat(core): add compact JWE encryption and decryption utilities feat(jose): add compact JWE encryption and decryption utilities Mar 21, 2026
@halvaradop halvaradop added the enhancement New feature or request label Mar 21, 2026
@halvaradop halvaradop merged commit 28c0baf into master Mar 21, 2026
6 of 8 checks passed
@halvaradop halvaradop deleted the feat/introduce-session-option branch March 21, 2026 02:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant