Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 15 additions & 31 deletions .github/workflows/cleanup-deno-dependency-branches.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,43 +39,27 @@ jobs:

const owner = context.repo.owner;
const repo = context.repo.repo;

const matchingRefs = await github.rest.git.listMatchingRefs({
owner,
repo,
ref: `heads/${prefix}`,
});
const branch = pr.head.ref;

const openPulls = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
const protectedHeads = new Set(openPulls.map((openPr) => openPr.head.ref));

let deleted = 0;

for (const ref of matchingRefs.data) {
const branch = ref.ref.replace('refs/heads/', '');

if (branch === 'main') continue;
if (protectedHeads.has(branch)) {
core.info(`Keeping ${branch}: branch is used by an open pull request.`);
continue;
}

try {
await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${branch}`,
});
core.info(`Deleted ${branch}`);
deleted += 1;
} catch (error) {
core.warning(`Could not delete ${branch}: ${error.message}`);
}
if (openPulls.some((openPr) => openPr.head.ref === branch)) {
core.info(`Keeping ${branch}: branch is used by an open pull request.`);
return;
}

core.info(`Cleanup complete. Deleted ${deleted} branch(es).`);
try {
await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${branch}`,
});
core.info(`Deleted ${branch}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
core.warning(`Could not delete ${branch}: ${message}`);
}
18 changes: 16 additions & 2 deletions .github/workflows/deno-deps-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
update:
name: Update dependencies and open PR
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 40

steps:
- name: Checkout
Expand All @@ -34,6 +34,20 @@ jobs:
- name: Validate updated dependencies
run: deno task check

- name: Setup Deno for browser and E2E
uses: denoland/setup-deno@v2
with:
deno-version: 2.9.6

- name: Run e2e suite
run: deno task e2e

- name: Install Chromium
run: deno task browser:install --with-deps

- name: Run browser login tests
run: deno task test:browser

- name: Create pull request
uses: peter-evans/create-pull-request@v8
with:
Expand All @@ -45,7 +59,7 @@ jobs:
What this PR does:
- runs `deno outdated --update --latest`
- updates `deno.lock` when needed
- runs `deno task check` before opening the PR
- runs `deno task check`, `deno task e2e`, and `deno task test:browser` before opening the PR
branch: chore/deno-dependencies
delete-branch: true
labels: dependencies
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,40 @@

All notable changes to this package are documented in this file.

## 0.4.0 - 2026-10-02

- Revalidated every session against `findUserById`. A session ends when the user
is missing, inactive, or no longer matches the stored email, `authVersion`, or
super-admin flag. With RBAC enabled, `permissionsVersion` must match too.
Added `revokeUserSessions(userId)`.
- Stored session ids as SHA-256 hashes. The cookie still carries the raw id.
- Kept one outstanding magic link per user and required its current-user pointer
during verification. Links written before this version cannot be consumed.
- Required the binding secret whenever a link stored one. IP and user-agent
matching remains the path for links issued without a binding secret.
- Canonicalized `requestIp` before rate-limit keys and IP binding, and rejected
missing or invalid addresses. Added a separate verifier-failure limiter.
- Tightened email checks and made `*@domain` match that domain only. Mail is
sent to the normalized address that passed the allowlist.
- Added successful-send quotas of 10 per email and 30 per IP each 15 minutes.
Unknown users do not consume that budget. KV retries stop after 8 attempts.
- Replaced the 30 day cookie default with the 7 day session lifetime. Default
cookie names are `__Host-session` and `__Host-ml-bind`.
- Added `buildVerifyResponseHeaders`, `magicLinkVerifyPath`,
`renderMagicLinkEmail`, and `sessionCookieMaxAgeSeconds()`. The built-in login
email is English.
- Rejected `appBaseUrl` values that contain credentials, a query, or a hash. An
unknown RBAC role no longer throws, and a missing role is no longer stored as
`viewer`.
- Stopped publishing `nodemailer` and `playwright` on the package import map.
Unit tests use in-memory KV. Dependency-update cleanup deletes only the merged
branch, and that workflow also runs the E2E and browser suites.

Upgrade: existing sessions and magic links stop working, so users need a new
link. HTTP development must set non-prefix cookie names and `secure: false`.
`findUserById` must return the live user or `getSession()` ends the session.
Idle expiry is a fixed deadline measured from issuance.

## 0.3.0 - 2026-09-10

- Bound newly issued magic links to the user's email and credential version.
Expand Down
41 changes: 23 additions & 18 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ deno task test:docs
containerized Deno test app, executes the E2E suite against the running stack,
and removes the containers afterward.

`deno task check` runs formatting, lint, unit/security/cookie tests, and
extracted README example tests. The example tests typecheck all five
authentication and authorization examples and execute their login handlers with
isolated KV and mail fixtures. They require permission to launch `deno`
subprocesses.
`deno task check` runs formatting, lint, unit, security, cookie, and hardening
tests, then the extracted README example tests. The example tests typecheck all
five authentication and authorization examples and execute their login handlers
with isolated in-memory KV and mail fixtures. They require permission to launch
`deno` subprocesses and to create temporary files.

`deno task test:browser` runs Chromium against an isolated local HTTP fixture.
It checks browser cookie acceptance, cross-site email navigation, binding on a
Expand All @@ -45,8 +45,10 @@ browser and E2E tests while retaining Deno 2.7.11 for unit and documentation
compatibility checks.

The browser task uses full Deno permissions for Playwright's Linux `/proc`
checks and browser subprocess management. Unit and documentation tasks retain
their narrower permissions.
checks and browser subprocess management. Unit tests need `--unstable-kv` only.
Documentation tests add read, write, and permission to launch `deno`.
`nodemailer` and `playwright` are imported by the E2E app and browser test, not
by the published package.

E2E HTTP ports are bound to `127.0.0.1`; SMTP stays inside the Compose network.
The E2E app uses the socket peer address rather than forwarding headers, and its
Expand All @@ -62,21 +64,22 @@ The repository includes four automation files under `.github`:
`main` (including merges), and on manual dispatch. The E2E checkout path
includes spaces and Unicode to exercise filesystem URL conversion.
- `.github/workflows/deno-deps-update.yml` runs every Monday at 05:00 UTC and
opens a PR with `deno outdated --update --latest` changes only after the
`deno task check` gate passes.
opens a PR with `deno outdated --update --latest` changes only after
`deno task check`, `deno task e2e`, and `deno task test:browser` pass. The job
timeout is 40 minutes.
- `.github/workflows/cleanup-deno-dependency-branches.yml` runs when a
`chore/deno-dependencies*` pull request is merged into `main` and deletes
orphaned dependency-update branches that are no longer used by open PRs.
`chore/deno-dependencies*` pull request is merged into `main`. It deletes that
pull request's head branch when no open pull request still uses it.
- `.github/dependabot.yml` keeps GitHub Actions and Docker dependencies updated.
Dependabot does not currently provide a dedicated Deno/JSR ecosystem updater,
so Deno dependency updates are handled by the scheduled workflow above.

## Why tests need file permissions
## Why documentation tests need file permissions

The original unit suite opens an isolated local Deno KV database file per test;
the additional security tests use in-memory KV. Documentation tests create and
remove temporary TypeScript fixtures. That is why `--unstable-kv`,
`--allow-read`, and `--allow-write` are required for `deno test`.
Unit, security, cookie, and hardening tests open `Deno.openKv(":memory:")`, so
`deno task test` does not grant filesystem permissions. Documentation tests
write the README examples to a temporary directory and run them with `deno`.
That task needs `--allow-read`, `--allow-write`, and `--allow-run=deno`.

## Release checklist

Expand All @@ -92,6 +95,8 @@ remove temporary TypeScript fixtures. That is why `--unstable-kv`,

- Keep the public API small and explicit.
- Prefer readable control flow over clever abstractions.
- Treat redirect handling, token validation, and cookie serialization as
security-sensitive areas.
- Treat redirect handling, token validation, IP canonicalization, binding
checks, and cookie serialization as security-sensitive areas.
- `getSession()` must keep calling `findUserById`. A session lookup that trusts
only the KV record is stale.
- Keep package docs current with the published API surface.
Loading
Loading