Repro (15 lines)
```ts
import jwt from "jsonwebtoken";
import { readFileSync } from "fs";
const PEM = readFileSync("/tmp/skelpo-jwt.pem", "utf8");
const ALG = "ES256"; // module-level const
const tokA = jwt.sign({ sub: "a" }, PEM, { algorithm: "ES256", issuer: "z" });
const headA = JSON.parse(Buffer.from(tokA.split(".")[0]!, "base64url").toString("utf8"));
console.log("(A) literal → header.alg =", headA.alg);
const tokB = jwt.sign({ sub: "b" }, PEM, { algorithm: ALG, issuer: "z" });
const headB = JSON.parse(Buffer.from(tokB.split(".")[0]!, "base64url").toString("utf8"));
console.log("(B) const ref → header.alg =", headB.alg);
const opts = { algorithm: "ES256" as const, issuer: "z" };
const tokC = jwt.sign({ sub: "c" }, PEM, opts);
const headC = JSON.parse(Buffer.from(tokC.split(".")[0]!, "base64url").toString("utf8"));
console.log("(C) const opts → header.alg =", headC.alg);
```
Observed (perry 0.5.1008)
```
(A) literal → header.alg = ES256
(B) const ref → header.alg = HS256 ← uses PEM as HMAC secret
(C) const opts → header.alg = HS256 ← same
```
Expected (tsx / Node)
```
(A) literal → header.alg = ES256
(B) const ref → header.alg = ES256
(C) const opts → header.alg = ES256
```
Pointer
`crates/perry-codegen/src/lower_call/native.rs::lower_jsonwebtoken_sign` only routes when the option value is an inline string literal:
```rust
"algorithm" => {
if let Expr::String(algorithm) = val { // ← only matches inline literals
runtime = match algorithm.as_str() {
"ES256" => "js_jwt_sign_es256",
"RS256" => "js_jwt_sign_rs256",
_ => "js_jwt_sign",
};
} else {
let _ = lower_expr(ctx, val)?; // ← const ref + spread + computed all fall here
}
}
```
`Expr::Identifier` (const reference), spread fields (`...opts`), and any non-literal expression all silently fall through to `js_jwt_sign` (HS256). Mirror bug on `verify`'s `algorithms: [...]` branch.
How this surfaces in shop-admin
`server/crypto/jwt.ts`:
```ts
const ALG = "ES256";
const ISSUER = "skelpo-shop-admin";
export function issueAccessToken(claims: AccessTokenClaims): string {
return jwt.sign(claims, loadKey(), {
algorithm: ALG, // ← falls back to HS256
expiresIn: ACCESS_TOKEN_TTL_SEC,
issuer: ISSUER,
});
}
```
The server boots fine and tokens validate (sign/verify both fall through to HS256 the same way), but the ES256 P-256 PEM is used as an HMAC secret instead of an EC key. The application thinks it's using ES256 — TypeScript types say so — but the on-wire tokens are HS256.
Suggested fix
Resolve simple const references at compile time before the literal match. Most TS-bound `const X = "literal"` is statically known. A small symbol-table lookup in `lower_jsonwebtoken_sign` (and the verify variants) would catch the common case. The general `Expr` may need full constant-folding — out of scope for this issue.
Local checkout: perry 0.5.1008, clean main.
Repro (15 lines)
```ts
import jwt from "jsonwebtoken";
import { readFileSync } from "fs";
const PEM = readFileSync("/tmp/skelpo-jwt.pem", "utf8");
const ALG = "ES256"; // module-level const
const tokA = jwt.sign({ sub: "a" }, PEM, { algorithm: "ES256", issuer: "z" });
const headA = JSON.parse(Buffer.from(tokA.split(".")[0]!, "base64url").toString("utf8"));
console.log("(A) literal → header.alg =", headA.alg);
const tokB = jwt.sign({ sub: "b" }, PEM, { algorithm: ALG, issuer: "z" });
const headB = JSON.parse(Buffer.from(tokB.split(".")[0]!, "base64url").toString("utf8"));
console.log("(B) const ref → header.alg =", headB.alg);
const opts = { algorithm: "ES256" as const, issuer: "z" };
const tokC = jwt.sign({ sub: "c" }, PEM, opts);
const headC = JSON.parse(Buffer.from(tokC.split(".")[0]!, "base64url").toString("utf8"));
console.log("(C) const opts → header.alg =", headC.alg);
```
Observed (perry 0.5.1008)
```
(A) literal → header.alg = ES256
(B) const ref → header.alg = HS256 ← uses PEM as HMAC secret
(C) const opts → header.alg = HS256 ← same
```
Expected (tsx / Node)
```
(A) literal → header.alg = ES256
(B) const ref → header.alg = ES256
(C) const opts → header.alg = ES256
```
Pointer
`crates/perry-codegen/src/lower_call/native.rs::lower_jsonwebtoken_sign` only routes when the option value is an inline string literal:
```rust
"algorithm" => {
if let Expr::String(algorithm) = val { // ← only matches inline literals
runtime = match algorithm.as_str() {
"ES256" => "js_jwt_sign_es256",
"RS256" => "js_jwt_sign_rs256",
_ => "js_jwt_sign",
};
} else {
let _ = lower_expr(ctx, val)?; // ← const ref + spread + computed all fall here
}
}
```
`Expr::Identifier` (const reference), spread fields (`...opts`), and any non-literal expression all silently fall through to `js_jwt_sign` (HS256). Mirror bug on `verify`'s `algorithms: [...]` branch.
How this surfaces in shop-admin
`server/crypto/jwt.ts`:
```ts
const ALG = "ES256";
const ISSUER = "skelpo-shop-admin";
export function issueAccessToken(claims: AccessTokenClaims): string {
return jwt.sign(claims, loadKey(), {
algorithm: ALG, // ← falls back to HS256
expiresIn: ACCESS_TOKEN_TTL_SEC,
issuer: ISSUER,
});
}
```
The server boots fine and tokens validate (sign/verify both fall through to HS256 the same way), but the ES256 P-256 PEM is used as an HMAC secret instead of an EC key. The application thinks it's using ES256 — TypeScript types say so — but the on-wire tokens are HS256.
Suggested fix
Resolve simple const references at compile time before the literal match. Most TS-bound `const X = "literal"` is statically known. A small symbol-table lookup in `lower_jsonwebtoken_sign` (and the verify variants) would catch the common case. The general `Expr` may need full constant-folding — out of scope for this issue.
Local checkout: perry 0.5.1008, clean main.