Skip to content

feat(extension): add offline authenticator for chrome and firefox - #116

Merged
denizlg24 merged 1 commit into
mainfrom
feat/authenticator-extension
Aug 4, 2026
Merged

feat(extension): add offline authenticator for chrome and firefox#116
denizlg24 merged 1 commit into
mainfrom
feat/authenticator-extension

Conversation

@denizlg24

@denizlg24 denizlg24 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Codes are generated locally from an encrypted vault instead of being polled, so they keep working when the server does not. Secrets come from a new admin-only export route, are sealed with AES-256-GCM under a passphrase-derived key, and accounts added in the extension sync back upstream.

Server-side deletions mirror into a local trash rather than dropping the secret outright, and a release workflow builds store-submittable packages whenever the extension version changes on main.

Summary by CodeRabbit

  • New Features

    • Released Authenticator browser extension for offline TOTP code generation and management.
    • Encrypted, locally-stored account vault with optional server synchronization.
    • Account creation, editing, and deletion with visual countdown timer.
    • Automatic account locking and periodic background synchronization.
    • Trash recovery system for deleted accounts.
    • Support for Chrome and Firefox browsers.
  • Documentation

    • Added extension privacy policy, development guide, and store listing documentation.

Codes are generated locally from an encrypted vault instead of being polled,
so they keep working when the server does not. Secrets come from a new
admin-only export route, are sealed with AES-256-GCM under a passphrase-derived
key, and accounts added in the extension sync back upstream.

Server-side deletions mirror into a local trash rather than dropping the secret
outright, and a release workflow builds store-submittable packages whenever the
extension version changes on main.
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cloud-denizlg24 Ready Ready Preview Aug 4, 2026 11:05am
denizlg24-com Ready Ready Preview Aug 4, 2026 11:05am
denizlg24-com-envoy Ready Ready Preview Aug 4, 2026 11:05am
denizlg24-email-classifier Ready Ready Preview Aug 4, 2026 11:05am
storage-denizlg24 Ready Ready Preview Aug 4, 2026 11:05am

Request Review

@vercel
vercel Bot temporarily deployed to Preview – cloud-denizlg24 August 4, 2026 11:04 Inactive
@vercel
vercel Bot temporarily deployed to Preview – denizlg24-com-envoy August 4, 2026 11:04 Inactive
@vercel
vercel Bot temporarily deployed to Preview – denizlg24-com August 4, 2026 11:05 Inactive
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds a new browser extension application supporting offline-first TOTP code generation with encrypted local vault storage. It includes cryptography primitives, vault entry management, server synchronization, a background worker with alarm-driven periodic sync and auto-lock, popup and options UI pages, setup/unlock/add/edit account flows, and otpauth URI parsing. Build tooling covers Vite configuration, manifest generation for Chrome and Firefox, icon generation, packaging scripts, and a GitHub Actions release workflow. On the server side, it adds an admin-authenticated authenticator export endpoint, an exportAccounts function, corresponding Zod schemas, and an extension privacy policy page.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the addition of an offline authenticator extension for Chrome and Firefox.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@denizlg24
denizlg24 merged commit 0bf30b9 into main Aug 4, 2026
9 of 10 checks passed
@denizlg24
denizlg24 deleted the feat/authenticator-extension branch August 4, 2026 11:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 23

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release-extension.yml:
- Around line 11-12: Update the workflow-level permissions to contents: read,
add contents: write specifically to the release job, and set
persist-credentials: false on checkout steps in prepare and build that only
require repository access. Keep the release checkout’s credential behavior
unchanged unless it does not need the write token.
- Around line 36-49: Update the workflow step containing current_version and
previous_version to pass github.event_name and github.event.before through env,
then reference those shell variables instead of embedding expressions in the run
script. Handle an all-zero or otherwise unresolvable previous SHA by treating
the previous version as the current version, so the comparison reports no
version change when no prior package version is available.

In `@apps/extension/scripts/generate-icons.ts`:
- Around line 135-141: Update the row buffer creation inside the icon-generation
loop to add pixels.byteOffset to the underlying-buffer offset before copying
each row. Preserve the existing stride and destination offsets so both full
arrays and subarray views produce correct PNG output.

In `@apps/extension/scripts/manifest.ts`:
- Line 69: Align the Firefox minimum version with the manifest’s
optional_host_permissions key: either emit the equivalent optional hosts through
optional_permissions for Firefox 115.0–127.0, or update
browser_specific_settings.gecko.strict_min_version to "128.0" if
optional_host_permissions remains the sole key.

In `@apps/extension/scripts/package.ts`:
- Around line 27-29: Update the FIXED_MTIME definition to construct a Date using
explicit local calendar fields for January 1, 1980, rather than Date.UTC.
Preserve the fixed timestamp’s reproducibility while ensuring fflate’s
local-field encoding remains within the ZIP format’s valid 1980 minimum in every
timezone.

In `@apps/extension/src/background/index.ts`:
- Around line 62-72: Update handleSetup to read the existing vault record before
calling createVault, and guard creation using the worker’s existing single-vault
invariant. If a record already exists, do not replace it or modify vault state;
only create and store a key, then sync when no record is present.
- Around line 93-122: Preserve the mutation-then-sync flow in the addAccounts,
editAccount, deleteAccount, and restoreAccount cases, but stop swallowing sync
failures with catch(() => undefined). Add or reuse the syncQuietly helper with
the same error-recording behavior used by the tick path, then replace each
direct sync catch with await syncQuietly(key) so failed pushes update
lastSyncError and sync state.

In `@apps/extension/src/components/add-account-dialog.tsx`:
- Around line 216-228: Validate and clamp digits and period in the Add button’s
submit callback before constructing the account passed to submit/onAdd. Parse
them as integers, reject non-integer or out-of-range values (digits 6–10, period
10–120), and do not invoke onAdd for invalid input; preserve the existing
defaults only where appropriate.

In `@apps/extension/src/hooks/use-codes.ts`:
- Around line 33-36: Update the useEffect refresh logic around computeAll and
setCodes to align updates with wall-clock second boundaries instead of starting
setInterval immediately. Schedule the first refresh for the next second
boundary, then continue refreshing once per second, while preserving cleanup of
all timers and the initial immediate code computation.

In `@apps/extension/src/hooks/use-vault-state.ts`:
- Around line 76-81: Update the storage change listener in the useVaultState
effect to inspect the changed area and keys, responding only to local storage
changes containing VAULT_KEY or PREFERENCES_KEY. Export those key constants from
../lib/storage and use them in the filter, leaving unrelated areas and keys
without calling refresh.

In `@apps/extension/src/lib/browser.ts`:
- Around line 21-24: Update originPattern to use url.hostname rather than
url.host when constructing the host permission pattern, so explicit API ports
are omitted while preserving the protocol and wildcard suffix.

In `@apps/extension/src/lib/clipboard.ts`:
- Around line 24-28: Update copyText’s execCommand("copy") handling to inspect
its boolean result and throw when it returns false, while preserving the finally
cleanup that removes scratch. Ensure failures reject so callers do not report a
successful copy.

In `@apps/extension/src/lib/crypto.test.ts`:
- Around line 36-42: Await the rejection assertion in the test “a wrong
passphrase fails authentication rather than returning garbage” by awaiting the
promise returned from expect(decryptJson(...)).rejects.toThrow(). Keep the
existing wrong-key setup and expected rejection unchanged.

In `@apps/extension/src/lib/entries.ts`:
- Around line 104-116: Update the trash-entry reconstruction in the visible
entries flow so serverId and serverUpdatedAt are cleared whenever pendingDelete
is false, including local deletions; retain the existing server identity only
while deletion remains pending. Add a regression test covering reason "local"
with pendingDelete false and verify both server fields are null.

In `@apps/extension/src/lib/sync.test.ts`:
- Around line 141-144: Await both rejection assertions in
apps/extension/src/lib/sync.test.ts at lines 141-144 and 305-312: add await to
the expect(...).rejects.toThrow calls in the “refuses to run without an API key”
test and the server-reachability failure test, ensuring the later vault equality
assertion runs after syncVault settles.

In `@apps/extension/src/lib/sync.ts`:
- Around line 158-162: Validate the response from fetchExport before processing
it in the sync flow, using the shared authenticatorExportSchema from
packages/schemas; replace the unchecked IAuthenticatorExport cast with schema
parsing and use the parsed payload for remote.accounts and vault writes,
propagating validation failures instead of persisting malformed data.
- Around line 112-132: Update the successful create path in syncVault’s
pending-push loop to persist the updated entries immediately after each
createRemoteAccount succeeds, including the returned serverId and pendingPush
false state. Reuse the existing vault persistence mechanism so later sync
failures cannot lose the adopted server id and recreate the account.

In `@apps/extension/src/lib/vault.ts`:
- Around line 96-112: The write path in writeVault must reject stale or
unauthenticated session keys before persisting ciphertext. Validate that
currentKey successfully matches the current vault record’s
authentication/revision state (using the existing WrongPassphraseError or
revision/salt check) before calling writeVaultRecord, while preserving the
existing KDF reuse for valid writes and ensuring changePassphrase remains
protected by its current read/serialization flow.

In `@apps/extension/src/pages/options/options.tsx`:
- Around line 260-267: Update the onBlur handler to handle failures from
onCommit(parsed): await or otherwise catch the rejected promise and call
setDraft(String(value)) when the commit fails, while preserving the existing
validation reset and successful commit behavior.
- Around line 391-398: Defer URL revocation in the export flow after
anchor.click() by scheduling URL.revokeObjectURL(url) on the next event loop,
ensuring the blob remains available while the browser begins downloading. Keep
the existing download filename and click behavior unchanged.

In `@apps/extension/src/pages/popup/popup.tsx`:
- Around line 206-208: Update the onDelete handler in the popup entry action to
handle the promise returned by send for the deleteAccount request instead of
discarding it. Await or catch failures and route the error into the existing
failure state or user-facing error handling, while preserving the current
entry.id request payload.
- Around line 177-190: Update the search-controls render condition around the
Input to keep it visible when either entries.length exceeds five or search is
non-empty. Preserve the existing filtering behavior and allow users to clear an
active search after the account count drops to five or fewer.
- Around line 66-67: Update the entries selection in the popup component so
non-"unlocked" states reuse a stable empty-array reference instead of allocating
a new array on each render. Keep passing the unlocked payload entries to
useCodes, and define or reuse a module-level empty-array constant for the
fallback.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ebe73b95-01cc-4f3e-95d3-85176488249c

📥 Commits

Reviewing files that changed from the base of the PR and between 59923e0 and 1842f09.

⛔ Files ignored due to path filters (3)
  • CLAUDE.md is excluded by !*.md
  • README.md is excluded by !*.md
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (53)
  • .github/workflows/release-extension.yml
  • apps/extension/.gitignore
  • apps/extension/README.md
  • apps/extension/SOURCE.md
  • apps/extension/bunfig.toml
  • apps/extension/options.html
  • apps/extension/package.json
  • apps/extension/popup.html
  • apps/extension/scripts/build.ts
  • apps/extension/scripts/generate-icons.ts
  • apps/extension/scripts/manifest.ts
  • apps/extension/scripts/package.ts
  • apps/extension/src/background/index.ts
  • apps/extension/src/components/account-row.tsx
  • apps/extension/src/components/add-account-dialog.tsx
  • apps/extension/src/components/countdown-ring.tsx
  • apps/extension/src/components/setup-screen.tsx
  • apps/extension/src/components/unlock-screen.tsx
  • apps/extension/src/global.d.ts
  • apps/extension/src/hooks/use-codes.ts
  • apps/extension/src/hooks/use-vault-state.ts
  • apps/extension/src/lib/api.ts
  • apps/extension/src/lib/browser.ts
  • apps/extension/src/lib/clipboard.ts
  • apps/extension/src/lib/crypto.test.ts
  • apps/extension/src/lib/crypto.ts
  • apps/extension/src/lib/entries.test.ts
  • apps/extension/src/lib/entries.ts
  • apps/extension/src/lib/format.ts
  • apps/extension/src/lib/messages.ts
  • apps/extension/src/lib/otpauth-uri.ts
  • apps/extension/src/lib/session.ts
  • apps/extension/src/lib/storage.ts
  • apps/extension/src/lib/sync.test.ts
  • apps/extension/src/lib/sync.ts
  • apps/extension/src/lib/theme.ts
  • apps/extension/src/lib/totp.test.ts
  • apps/extension/src/lib/totp.ts
  • apps/extension/src/lib/types.ts
  • apps/extension/src/lib/vault.ts
  • apps/extension/src/pages/options/main.tsx
  • apps/extension/src/pages/options/options.tsx
  • apps/extension/src/pages/popup/main.tsx
  • apps/extension/src/pages/popup/popup.tsx
  • apps/extension/src/styles/globals.css
  • apps/extension/store/listing.md
  • apps/extension/test-setup.ts
  • apps/extension/tsconfig.json
  • apps/extension/vite.config.ts
  • apps/web/app/api/admin/authenticator/export/route.ts
  • apps/web/app/extension-privacy/page.tsx
  • apps/web/lib/authenticator.ts
  • packages/schemas/src/authenticator.ts

Comment on lines +11 to +12
permissions:
contents: write

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow file =="
cat -n .github/workflows/release-extension.yml

echo
echo "== package/config references =="
fd -p 'package.json|bun.lockb|bun.lock|pnpm-lock.yaml|yarn.lock|x86' .github/workflows/release-extension.yml 2>/dev/null || true

echo
echo "== relevant checkouts/build hooks across repo =="
rg -n "bun(inst|run)|build|pre|post|prepare|install|hooks|lint|typecheck|verify|actions/checkout|persist-credentials|contents: write|permissions:" -S .github package.json tools scripts 2>/dev/null || true

Repository: denizlg24/denizlg24.com

Length of output: 19569


🌐 Web query:

actions/checkout persist-credentials default true commit GITHUB_TOKEN contents write permissions

💡 Result:

The actions/checkout action has a persist-credentials input that defaults to true [1][2]. When set to true, the authentication token (such as the GITHUB_TOKEN) used to check out the repository is persisted in the local Git configuration [1][3]. This allows subsequent steps in the same job to run authenticated Git commands without needing to manually re-authenticate [1][4]. The token is removed automatically during the post-job cleanup [1][3]. Regarding GITHUB_TOKEN permissions, while actions/checkout defaults to persisting these credentials [1][2], the actual capabilities available to those credentials depend on the permissions explicitly granted to the GITHUB_TOKEN within your workflow file [5][6]. The default GITHUB_TOKEN permissions for GitHub Actions workflows include only read access for repository contents (contents: read) [6]. To enable write operations (such as pushing code or creating branches), you must explicitly grant write permissions in your workflow configuration [6]: permissions: contents: write If you require a job to perform write actions, it is a recommended security practice to use the principle of least privilege by explicitly setting the required permissions in your workflow file rather than relying on default or broad access [5][6]. If your workflow only needs to clone or fetch the repository, keeping the default contents: read is sufficient [6]. To prevent the GITHUB_TOKEN from being persisted in the local Git config for security reasons, you can set persist-credentials to false in the checkout step [1][7].

Citations:


Security Misconfiguration (CWE-250)

Scope contents: write to the release job and disable credential persistence in build steps.

The workflow-level contents: write grant also reaches prepare and build, whose checkouts persist the token by default. The build job already executes bun commands and repo scripts while that token is available, even without installing extra packages. Set the workflow default to contents: read, give release job-level write permissions, and add persist-credentials: false to the checkouts that only need repository access.

🧰 Tools
🪛 zizmor (1.28.0)

[error] 12-12: overly broad permissions (excessive-permissions): contents: write is overly broad at the workflow level

(excessive-permissions)


[warning] 12-12: permissions without explanatory comments (undocumented-permissions): needs an explanatory comment

(undocumented-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release-extension.yml around lines 11 - 12, Update the
workflow-level permissions to contents: read, add contents: write specifically
to the release job, and set persist-credentials: false on checkout steps in
prepare and build that only require repository access. Keep the release
checkout’s credential behavior unchanged unless it does not need the write
token.

Source: Linters/SAST tools

Comment on lines +36 to +49
current_version="$(jq -r '.version' apps/extension/package.json)"

if [[ "${{ github.event_name }}" != "push" ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "tag=ext-v$current_version" >> "$GITHUB_OUTPUT"
echo "version=$current_version" >> "$GITHUB_OUTPUT"
exit 0
fi

previous_version="$(
git show "${{ github.event.before }}:apps/extension/package.json" 2>/dev/null |
jq -r '.version' ||
true
)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Workflow around detected lines:\n'
sed -n '1,90p' .github/workflows/release-extension.yml

printf '\nSearch for release-extension reference and outputs:\n'
rg -n "release-extension|version|changed|tag=ext-v|previous_version|git show|github\.event\.before|github\.event_name" .github apps/extension -S

Repository: denizlg24/denizlg24.com

Length of output: 18017


🌐 Web query:

GitHub Actions github.event.before push all-zero SHA zero commit

💡 Result:

In GitHub Actions, the github.event.before property is set to an all-zero SHA (0000000000000000000000000000000000000000) when there is no previous commit reference for the event [1]. This most commonly occurs during the first push to a new branch, the creation of a new tag, or following a force push [2][3][1]. Because this all-zero string is not a valid git object, workflows that attempt to use github.event.before directly in commands like git fetch or git diff will encounter fatal errors [4][2][3]. To handle this, developers commonly implement conditional logic to detect the null SHA and provide a fallback [3][5][6]. Common strategies include: 1. Skipping the command: If the operation is intended to compare changes between commits, skip the operation entirely when the before SHA is null, as there is no prior state to compare against [4][7]. 2. Using an empty tree: For git diff commands, substituting the null SHA with the git empty-tree SHA (4b825dc642cb6eb9a060e54bf8d69288fbee4904) can allow the diff to treat all files in the initial commit as newly added [2]. 3. Falling back to an alternative base: Some workflows fall back to a default branch (e.g., origin/main), the parent of the first commit in the push, or a recent commit timestamp to establish a base [3][5][8]. Example of defensive logic in a workflow: if [[ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ]]; then echo "First push detected, skipping base comparison." else git diff "${{ github.event.before }}" "${{ github.sha }}" fi

Citations:


🌐 Web query:

GitHub Actions context payload github.event.before contains shell metacharacters

💡 Result:

Using GitHub Actions context variables—such as github.event.before—directly within run steps creates a security risk known as script or shell injection [1][2][3]. This occurs because GitHub Actions evaluates the ${{ }} expression and substitutes its raw value into your shell script before the shell executes it [1][4][5]. If an attacker can influence that value to contain shell metacharacters (e.g., quotes, semicolons, backticks, or subshell syntax), they can break out of the intended command string and execute arbitrary code on the runner [1][2][3]. While github.event.before is a SHA reference and therefore less likely to be directly manipulated by an attacker compared to fields like issue titles or pull request bodies, it is still considered a best practice to avoid direct interpolation for any context variable [6][7][5]. Security tools and hardening guides flag this pattern because it violates defensive programming principles [6][8][9]. To prevent shell injection, you should always avoid using the ${{ }} syntax directly in run commands [10][5][3]. Instead, pass the context value into an environment variable using the env: block [2][4][3]. Because environment variables are handled as data rather than script code, the shell will not interpret metacharacters within the variable's value [11][5][3]. Recommended pattern: # ❌ VULNERABLE: Direct interpolation run: git diff --name-only ${{ github.event.before }} ${{ github.sha }} # ✅ SECURE: Using environment variables env: BEFORE_SHA: ${{ github.event.before }} run: git diff --name-only "$BEFORE_SHA" "${{ github.sha }}" By assigning the value to an environment variable, the shell treats the content as a safe string [11][3]. Additionally, quoting the variable (e.g., "$BEFORE_SHA") is a standard shell best practice to prevent word splitting [10][5]. Automated security scanners such as CodeQL and zizmor are designed to detect unsafe inline interpolation and should be used to audit your workflows [2][10][6][7].

Citations:


🌐 Web query:

GitHub Actions github.event.before ref type SHA string

💡 Result:

In GitHub Actions, github.event.before is a property available within the github context for specific event types, most notably the push event [1][2]. It is a string representing the SHA (Secure Hash Algorithm) of the most recent commit on the reference (branch or tag) before the push occurred [3][4][2]. Key technical details regarding github.event.before: 1. Type and Format: It is a string containing a 40-character commit SHA [5][3]. 2. Usage: It is primarily used to determine the range of commits introduced by a push (i.e., the range between before and after [3][4][6]). 3. Null SHA Case: When a workflow is triggered by the creation of a new branch or tag, github.event.before is set to the "null SHA" (0000000000000000000000000000000000000000) [1][7]. Because this is not a valid git reference, attempting to use it directly in commands like git fetch or git diff without a conditional check can cause the workflow to fail [8][7][6]. 4. Best Practice: When using github.event.before in scripts, it is recommended to check if the value is the null SHA or empty to prevent errors during execution [7][6]. The github.event context mirrors the webhook payload for the event that triggered the workflow run [5][9][10]. Therefore, properties like before are specific to the structure of the push webhook payload and are not universal to all GitHub Actions events [5][9][3].

Citations:


🌐 Web query:

GitHub push payload before SHA webhook before ref name

💡 Result:

In a GitHub push event webhook payload, the fields appear in the JSON object at the top level [1][2]. While JSON objects are technically unordered collections of key-value pairs, the standard schema for a push event includes the following core fields [1][3]: ref: The full Git reference that was pushed (e.g., refs/heads/main or refs/tags/v3.14.1) [1][4]. before: The SHA of the most recent commit on the ref before the push [1][5]. after: The SHA of the most recent commit on the ref after the push [1][3]. These fields are essential for identifying the state of the branch or tag before and after the specific push action occurred [1][6]. When receiving the payload, you can use these SHAs to compare the changes included in the push [1][3]. [1][2]

Citations:


Injection (CWE-94): Improper Control of Generation of Code ('Code Injection')

Pass event data through env, and handle the all-zero previous SHA.

  1. ${{ github.event_name }} and ${{ github.event.before }} expand into the shell script before bash parses it. Assign them to env values and reference those shell variables in the run block.
  2. On the first push to a ref or after a force push, github.event.before can be the all-zero SHA. git show then fails, so previous_version becomes empty and the workflow reports changed=true even if current_version did not change. Treat an unresolvable previous commit as no version change.
🧰 Tools
🪛 zizmor (1.28.0)

[warning] 38-38: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[warning] 46-46: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release-extension.yml around lines 36 - 49, Update the
workflow step containing current_version and previous_version to pass
github.event_name and github.event.before through env, then reference those
shell variables instead of embedding expressions in the run script. Handle an
all-zero or otherwise unresolvable previous SHA by treating the previous version
as the current version, so the comparison reports no version change when no
prior package version is available.

Source: Linters/SAST tools

Comment on lines +135 to +141
for (let row = 0; row < size; row++) {
raw[row * (stride + 1)] = 0; // filter type: none
Buffer.from(pixels.buffer, row * stride, stride).copy(
raw,
row * (stride + 1) + 1,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Include pixels.byteOffset in the row views.

Buffer.from(pixels.buffer, ...) addresses the underlying ArrayBuffer, not the view. renderPixels returns a fresh array, so the offset is 0 now. If a caller later passes a subarray, every row reads from the wrong offset and the PNG output is silently corrupt.

♻️ Proposed change
-    Buffer.from(pixels.buffer, row * stride, stride).copy(
-      raw,
-      row * (stride + 1) + 1,
-    );
+    Buffer.from(
+      pixels.buffer,
+      pixels.byteOffset + row * stride,
+      stride,
+    ).copy(raw, row * (stride + 1) + 1);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (let row = 0; row < size; row++) {
raw[row * (stride + 1)] = 0; // filter type: none
Buffer.from(pixels.buffer, row * stride, stride).copy(
raw,
row * (stride + 1) + 1,
);
}
for (let row = 0; row < size; row++) {
raw[row * (stride + 1)] = 0; // filter type: none
Buffer.from(
pixels.buffer,
pixels.byteOffset + row * stride,
stride,
).copy(raw, row * (stride + 1) + 1);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/scripts/generate-icons.ts` around lines 135 - 141, Update the
row buffer creation inside the icon-generation loop to add pixels.byteOffset to
the underlying-buffer offset before copying each row. Preserve the existing
stride and destination offsets so both full arrays and subarray views produce
correct PNG output.

// Only the server this extension syncs with. Anything else the owner points
// it at is requested at runtime through optional_host_permissions.
host_permissions: [apiOrigin],
optional_host_permissions: ["https://*/*"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In which Firefox version did optional_host_permissions become supported in manifest.json?

💡 Result:

Firefox introduced support for the optional_host_permissions manifest.json key in version 128 [1][2][3]. This key is intended for use with Manifest V3 or higher to specify host permissions that an extension can request at runtime [4][5][3]. While older versions of Firefox allowed host permissions to be defined within the optional_permissions key, the optional_host_permissions key is the current recommended standard [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'apps/extension/scripts/(manifest|build)\.ts$|README|package\.json|firefox' || true

echo "== manifest outline =="
ast-grep outline apps/extension/scripts/manifest.ts || true

echo "== relevant manifest.ts lines =="
cat -n apps/extension/scripts/manifest.ts | sed -n '1,140p'

echo "== search for permissions.request / optional host usage =="
rg -n "permissions\.request|optional_host_permissions|optional_permissions|host_permissions" apps/extension -S || true

Repository: denizlg24/denizlg24.com

Length of output: 7200


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== browser.ts outline =="
ast-grep outline apps/extension/src/lib/browser.ts || true

echo "== browser.ts relevant lines =="
cat -n apps/extension/src/lib/browser.ts | sed -n '1,120p'

echo "== build manifest call sites/options =="
rg -n "buildManifest|apiOrigin|optional_host_permissions|strict_min_version|browser_specific_settings" apps/extension -S || true

echo "== package scripts =="
cat apps/extension/package.json | jq '{scripts, extensions}' 2>/dev/null || true

Repository: denizlg24/denizlg24.com

Length of output: 4004


Align Firefox support versions with the optional permission key.

Firefox 128 is the first release that supports optional_host_permissions; Firefox 115.0–127.0 only supports optional hosts under optional_permissions. Keep the current floor only if the Firefox manifest also emits optional_permissions, otherwise raise browser_specific_settings.gecko.strict_min_version to "128.0".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/scripts/manifest.ts` at line 69, Align the Firefox minimum
version with the manifest’s optional_host_permissions key: either emit the
equivalent optional hosts through optional_permissions for Firefox 115.0–127.0,
or update browser_specific_settings.gecko.strict_min_version to "128.0" if
optional_host_permissions remains the sole key.

Source: Linters/SAST tools

Comment on lines +27 to +29
/** 1980-01-01, the earliest timestamp the ZIP format can store. Pinning it keeps
* the same build reproducible byte for byte. */
const FIXED_MTIME = Date.UTC(1980, 0, 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

How does fflate zipSync interpret the mtime option and what are its valid date bounds?

💡 Result:

In the fflate library, the mtime option in zipSync allows you to specify the last modification time for files in a ZIP archive [1][2]. By default, fflate uses the current system time if this option is not provided [1][3]. The option accepts a string, a number (representing a Unix epoch timestamp), or a JavaScript Date object [1][2]. Regarding how it is interpreted and its constraints: 1. Local Timezone Encoding: The mtime is encoded into the ZIP file's DOS timestamp format using the Date object's local time components (e.g., getFullYear, getHours) [4][5]. This means that the exact byte representation in the ZIP archive may vary depending on the local timezone of the environment where the archive is created [4][5]. To ensure deterministic, reproducible builds regardless of the environment's timezone, it is recommended to provide a Date object explicitly constructed with specific local date components (e.g., new Date(2020, 0, 1)) rather than a UTC epoch timestamp [4][5]. 2. Valid Date Bounds: ZIP files use a legacy DOS format to store timestamps, which has a minimum representable date of January 1, 1980 [5]. Consequently, any constant date on or after this date is valid [5]. Dates prior to this epoch are not supported by the underlying ZIP specification's time field and should be avoided to ensure compatibility [5]. Top results: [4][5][6]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package.ts =="
cat -n apps/extension/scripts/package.ts | sed -n '1,120p'

echo
echo "== package references =="
rg -n "FIXED_MTIME|zipSync|mtime" apps/extension/scripts apps -g '!node_modules' -g '!dist' || true

echo
echo "== lock/package deps fflate =="
if [ -f apps/extension/package.json ]; then
  cat apps/extension/package.json | jq '.dependencies, .devDependencies' 2>/dev/null || cat apps/extension/package.json
fi
rg -n '"fflate"|"fflate@' package.json pnpm-lock.yaml yarn.lock bun.lockb bun.lock 2>/dev/null || true

echo
echo "== timezone behavior probe =="
node - <<'JS'
const offsetUTC = Date.UTC(1980, 0, 1);
const local = new Date(offsetUTC);

function dumpTZ(name, tz) {
  process.env.TZ = tz;
  const d = new Date(Date.UTC(1980, 0, 1));
  const iso = d.toISOString();
  const str = d.toString();
  const minYear = d.getFullYear() < 1980;
  const ms = d.getTime();
  console.log(`${name} localDate=${str} ISO=${iso} yearBelow1980=${minYear}`);
}
for (const tz of ['UTC-5', 'Canada/Eastern', 'US/Pacific', 'UTC']) {
  dumpTZ(tz);
}
JS

Repository: denizlg24/denizlg24.com

Length of output: 7039


Use a local-time date for FIXED_MTIME.

fflate encodes mtime from the Date’s local fields into the ZIP DOS timestamp. Date.UTC(1980, 0, 1) represents a different local date in negative-offset timezones and will fall below ZIP’s 1980 minimum, breaking zipSync. Build a date with explicit local calendar fields so the release timestamp stays stable and valid everywhere.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/scripts/package.ts` around lines 27 - 29, Update the
FIXED_MTIME definition to construct a Date using explicit local calendar fields
for January 1, 1980, rather than Date.UTC. Preserve the fixed timestamp’s
reproducibility while ensuring fflate’s local-field encoding remains within the
ZIP format’s valid 1980 minimum in every timezone.

Comment on lines +260 to +267
onBlur={() => {
const parsed = Number(draft);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
setDraft(String(value));
return;
}
void onCommit(parsed);
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Surface a failed preference commit.

void onCommit(parsed) discards a rejected promise. If the background rejects updatePreferences, the input keeps showing the new value while the stored preference is unchanged. Revert the draft on failure.

♻️ Proposed refactor
-          void onCommit(parsed);
+          void onCommit(parsed).catch(() => setDraft(String(value)));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onBlur={() => {
const parsed = Number(draft);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
setDraft(String(value));
return;
}
void onCommit(parsed);
}}
onBlur={() => {
const parsed = Number(draft);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
setDraft(String(value));
return;
}
void onCommit(parsed).catch(() => setDraft(String(value)));
}}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/pages/options/options.tsx` around lines 260 - 267, Update
the onBlur handler to handle failures from onCommit(parsed): await or otherwise
catch the rejected promise and call setDraft(String(value)) when the commit
fails, while preserving the existing validation reset and successful commit
behavior.

Comment on lines +391 to +398
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `authenticator-vault-${new Date()
.toISOString()
.slice(0, 10)}.json`;
anchor.click();
URL.revokeObjectURL(url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does revoking a blob object URL immediately after anchor.click() cancel the download in Firefox?

💡 Result:

Yes, revoking a blob object URL immediately after calling anchor.click often cancels the download in Firefox [1][2][3]. The issue arises because the browser's download process is asynchronous [2][3][4]. When you trigger a download using anchor.click, the browser schedules the fetch operation for the blob. If you call URL.revokeObjectURL synchronously on the next line of code, you may revoke the object URL before the browser has actually started reading the data, causing the download to fail [2][3]. To avoid this, it is common practice to defer the revocation of the object URL using setTimeout(..., 0). This allows the JavaScript event loop to process the click initiation before the URL is revoked [2][5][6]. Example of the corrected pattern: const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'filename.ext'; anchor.click; // Defer revocation to the next event loop task setTimeout( => URL.revokeObjectURL(url), 0); While the web standard implies that browsers should internally handle the blob reference safely once a navigation/download is triggered [1][4], browser implementations—including Firefox—have historically struggled with this determinism, making the use of a small timeout a standard, reliable workaround [1][2][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant options file around the reported lines.
if [ -f apps/extension/src/pages/options/options.tsx ]; then
  sed -n '360,420p' apps/extension/src/pages/options/options.tsx | cat -n
else
  echo "File not found: apps/extension/src/pages/options/options.tsx"
  fd -a 'options\.tsx$' . | sed -n '1,20p'
fi

# Search for similar programmatic downloads to gauge repository patterns.
rg -n "URL\.createObjectURL|revokeObjectURL|anchor\.click\(\)" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true

Repository: denizlg24/denizlg24.com

Length of output: 5376


🌐 Web query:

MDN Document.body required for programmatic anchor click programmatic user activation

💡 Result:

There is no requirement to use document.body to programmatically trigger a user-activated anchor click. You can call the.click method directly on the specific HTMLAnchorElement [1][2]. To programmatically simulate a user interaction that satisfies browser security requirements (such as opening a popup or navigating), you should call the click method on the element itself [1][3]. This method is designed to simulate a real user click and will trigger the element's default activation behavior (e.g., following an href) if the element is not disabled [1][4]. Regarding the role of document.body: 1. Direct Triggering: Calling anchorElement.click is the standard and correct way to programmatically trigger an anchor [1][2]. 2. Event Delegation: Some developers use document.body as a target when setting up global event listeners for delegation [5] or when simulating clicks in testing environments (e.g., JSDOM or testing libraries) where a specific target element might not be available or fully supported [6][7]. 3. User Activation State: Browsers track "User Activation" (transient or sticky) based on actual user interaction [8][3]. Programmatically calling.click on an element generally does not grant "trusted" user activation status in the same way a physical mouse click or key press does [3]. Most modern browsers will block restricted actions (like window.open) if they are triggered by a script rather than a direct, hardware-based user event [3][9]. If your goal is to programmatically navigate or trigger behavior that typically requires user activation, simply calling.click on an anchor often will not bypass browser security restrictions meant to prevent abusive popups or unauthorized navigation [3].

Citations:


🌐 Web query:

MDN HTMLAnchorElement click programmatic click requirements body click download Firefox

💡 Result:

To programmatically trigger a download in Firefox using the HTMLAnchorElement.click method, it is required that the anchor element be present in the document DOM [1][2][3]. While simply creating an element with document.createElement("a") and calling.click may work in some other browsers, Firefox historically treats.click on an anchor element as a no-op if the element is not currently attached to the document body [1][2][4]. To successfully trigger the download, follow this pattern: 1. Create the anchor element: const link = document.createElement("a"); [2][3] 2. Configure it with the necessary attributes (e.g., href, download): link.href = url; link.download = "filename"; [2][3] 3. Append it to the document body: document.body.appendChild(link); [1][3] 4. Trigger the click: link.click; [1][2][3] 5. Remove it from the DOM immediately after: document.body.removeChild(link); (or link.remove;) [1][3][4] Note that using dispatchEvent to simulate a click (e.g., dispatching a MouseEvent) is generally not sufficient to trigger the default navigation or download behavior for anchor elements, as browsers restrict synthetic events to prevent unauthorized actions [5]. The.click method is the standard way to trigger these default actions, provided the DOM requirement is met [1][5].

Citations:


Defer the blob URL revocation after the download starts.

anchor.click() returns before the browser has fully fetched the blob, so URL.revokeObjectURL(url) can revoke the backing URL too early and cancel the export in Firefox. Trigger the click, then revoke the URL in the next event loop.

🐛 Proposed fix
     const url = URL.createObjectURL(blob);
     const anchor = document.createElement("a");
     anchor.href = url;
     anchor.download = `authenticator-vault-${new Date()
       .toISOString()
       .slice(0, 10)}.json`;
-    anchor.click();
-    URL.revokeObjectURL(url);
+    anchor.click();
+    setTimeout(() => URL.revokeObjectURL(url), 0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `authenticator-vault-${new Date()
.toISOString()
.slice(0, 10)}.json`;
anchor.click();
URL.revokeObjectURL(url);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `authenticator-vault-${new Date()
.toISOString()
.slice(0, 10)}.json`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/pages/options/options.tsx` around lines 391 - 398, Defer
URL revocation in the export flow after anchor.click() by scheduling
URL.revokeObjectURL(url) on the next event loop, ensuring the blob remains
available while the browser begins downloading. Keep the existing download
filename and click behavior unchanged.

Comment on lines +66 to +67
const entries = state.status === "unlocked" ? state.payload.entries : [];
const codes = useCodes(entries);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Prevent the non-unlocked render loop.

When state.status is not "unlocked", Line 66 creates a new empty array on every render. useCodes observes that new identity on Lines 33-36 of apps/extension/src/hooks/use-codes.ts and writes a new CodeMap. This schedules another render indefinitely. The loading, setup, and locked screens can hit React’s update-depth guard. Reuse a stable empty array.

Proposed fix
 import type { VaultEntry } from "../../lib/types";
 
+const EMPTY_ENTRIES: VaultEntry[] = [];
+
 function PopupShell({ children }: { children: React.ReactNode }) {
@@
-  const entries = state.status === "unlocked" ? state.payload.entries : [];
+  const entries =
+    state.status === "unlocked" ? state.payload.entries : EMPTY_ENTRIES;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const entries = state.status === "unlocked" ? state.payload.entries : [];
const codes = useCodes(entries);
const entries =
state.status === "unlocked" ? state.payload.entries : EMPTY_ENTRIES;
const codes = useCodes(entries);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/pages/popup/popup.tsx` around lines 66 - 67, Update the
entries selection in the popup component so non-"unlocked" states reuse a stable
empty-array reference instead of allocating a new array on each render. Keep
passing the unlocked payload entries to useCodes, and define or reuse a
module-level empty-array constant for the fallback.

Comment on lines +177 to +190
{entries.length > 5 && (
<div className="px-3 py-2 border-b border-border/50 shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search"
aria-label="Search accounts"
className="pl-8 h-7 text-xs"
/>
</div>
</div>
)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep an active search controllable.

If the account count drops from six to five while a search is active, this condition removes the input. The retained search value still filters filtered, so nonmatching accounts stay hidden with no visible way to clear the filter. Keep the input visible while search is non-empty.

Proposed fix
-      {entries.length > 5 && (
+      {(entries.length > 5 || search.length > 0) && (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{entries.length > 5 && (
<div className="px-3 py-2 border-b border-border/50 shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search"
aria-label="Search accounts"
className="pl-8 h-7 text-xs"
/>
</div>
</div>
)}
{(entries.length > 5 || search.length > 0) && (
<div className="px-3 py-2 border-b border-border/50 shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search"
aria-label="Search accounts"
className="pl-8 h-7 text-xs"
/>
</div>
</div>
)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/pages/popup/popup.tsx` around lines 177 - 190, Update the
search-controls render condition around the Input to keep it visible when either
entries.length exceeds five or search is non-empty. Preserve the existing
filtering behavior and allow users to clear an active search after the account
count drops to five or fewer.

Comment on lines +206 to +208
onDelete={() =>
void send({ type: "deleteAccount", id: entry.id })
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle rejected delete requests.

send rejects when the background does not respond or returns an error. Line 206 discards that promise. A failed delete therefore creates an unhandled promise rejection. Await the request and store or display its failure state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/pages/popup/popup.tsx` around lines 206 - 208, Update the
onDelete handler in the popup entry action to handle the promise returned by
send for the deleteAccount request instead of discarding it. Await or catch
failures and route the error into the existing failure state or user-facing
error handling, while preserving the current entry.id request payload.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant