npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@codefusion-cc/sign-in

v0.1.1

Published

Signing people in to a Cloudflare Worker app by several methods and keeping their sessions on D1: accounts with several identities and a rule for joining them by confirmed address, a random token in a __Host- cookie with only its hash stored, sliding and

Readme

@codefusion-cc/sign-in

Sign-in for a Worker app with a D1 database: accounts, the identities that prove them, sessions in a __Host- cookie, the routes a page calls, and the methods people sign in with. Which methods an app offers is data: each one is switched on and off in settings, without a deploy. Google is here (./google); a password, a code by mail, passkeys, Apple and Facebook are later entries on the same core, which imports no provider.

WebCrypto, fetch and D1 only. It builds on @codefusion-cc/workers-http (cookies, the same-origin check, JSON bodies, errors), @codefusion-cc/workers-crypto (random tokens) and, for ./google, @codefusion-cc/google-sign-in.

npm install @codefusion-cc/sign-in

| Entry | Runs in | What it holds | | --- | --- | --- | | . | Worker | createSignIn (the routes), createSessions (accounts and sessions), the method contract, safeReturnPath | | ./google | Worker | google(), the Google method | | ./browser | page | createSessionStore: who is signed in, which methods are offered, the actions | | ./google/browser | page | googleSignIn(store): the trip to Google and back | | ./react | page | useSession, useSignInMethods, GoogleButton | | ./migrations | build, tests | the tables as SQL (signInMigrations), and the codefusion-sign-in-migrations command | | ./testing | tests | a browser with a cookie jar, the Google method against a fake Google (testGoogle) and a sign-in with it through the real routes, a session without a provider |

In the Worker

import { createSignIn } from '@codefusion-cc/sign-in'
import { google } from '@codefusion-cc/sign-in/google'

const signIn = createSignIn({
  db: env.DB,
  methods: [google({ clientId: env.GOOGLE_CLIENT_ID })],
  rateLimiter: env.AUTH_LIMITER,
  onSignIn: ({ accountId, email, emailVerified }) => linkGuestOrders(env, accountId, emailVerified ? email : null),
  me: session => ({ role: roleOf(session.account) }),
})

// In the router: the sign-in routes, or null for any other path.
const answer = await signIn.handle(request)
if (answer) return answer

// In the app's own routes.
const session = await signIn.sessions.read(request)            // null when nobody is signed in
const staff = await signIn.sessions.require(request, { signedInWithinSeconds: 900, methods: ['google', 'passkey'] })

createSignIn makes no request by itself, so making one per request costs nothing. Everything is a parameter: the cookie names (cookieName, challengeCookieName), the lifetimes (slidingSeconds, absoluteSeconds, renewAfterSeconds), the routes' place (basePath, default /api/auth), the tables' prefix (tablePrefix).

Routes

| Route | What it does | | --- | --- | | GET <base>/methods | { methods: ['google', …] }: the ids offered now, in the order the app registered them. No secrets; cache-control: public, max-age=30. While no registered method is configured it is { methods: [] } without a read of D1, so an app with no client id or no tables yet answers; with one configured, a D1 failure is 503 sessions_unavailable, never an empty list. | | GET <base>/me | Who is signed in: { account, method, signedInAt, expiresAt } plus the fields of the me hook, or { account: null }. Never clears a cookie. | | POST <base>/logout | Ends this browser's session. | | POST <base>/account/delete | Runs onDeleteAccount, then deletes the account, its identities and every session of it. | | POST <base>/<method>/start | Starts a sign-in: sets the challenge cookie and answers what the method's begin returns. | | POST <base>/<method> | Completes it: the method verifies, the challenge is used up, the session starts, the answer is the me body. | | <base>/<method>/… | The method's own routes (Google's callback page). |

Every POST must come from the app's own origin (requireSameOrigin, plus allowedOrigins) with a JSON body, which a form on another site cannot send; a GET on a write route is a 405 and writes nothing. That holds for a method's own routes too, by construction: the core checks the request before the method sees it and hands it the parsed body, unless the method opened that one path (openPosts, below). Every answer but the list of methods is no-store. A refusal is JSON with a code, never a sentence to show: not_signed_in (401), recent_sign_in_required (403), method_disabled (403), method_not_configured (503), sign_in_refused (401, with problem), email_unverified (401), sign_in_unavailable (503, try again), sessions_unavailable (503, D1 failed), and those of workers-http (cross_origin, unsupported_media_type, rate_limited). The app words them.

Sessions

  • The token is 32 random bytes, in the cookie __Host-session (HttpOnly, Secure, SameSite=Lax, Path=/, no Domain). D1 holds only its SHA-256, so a copy of the database signs nobody in.
  • A sign-in always makes a new token and ends the session the browser came with; a cookie the browser brought is never adopted.
  • A session ends slidingSeconds after its last use (default 30 days) and absoluteSeconds after it started at the latest (default 180 days, the cookie's Max-Age). Reading is one statement; the new expiry is written at most once per renewAfterSeconds (default a day).
  • A session records the method that proved it and when: session.method, session.authenticatedAt. signedInWithin(session, seconds, { methods }) and sessions.require(request, { signedInWithinSeconds, methods }) ask for a recent sign-in, by some methods only when the app says so, before something sensitive.
  • Expired sessions and spent challenges are removed a few at a time by each sign-in. No cron.
  • What it costs: a sign-in is one D1 round trip of ten statements (one transaction: all of it or nothing), after one statement that spends the challenge; a read is one statement, and a second one once per renewAfterSeconds to write the new expiry. The switches are read once per 30 seconds per isolate. No statement runs per identity.
  • Errors: what a visitor or D1 can cause is a result or an HttpError of workers-http (D1 out of reach is a 503); what only a programming mistake causes (a cookie name without __Host-, a lifetime of 500 days, an identity without a subject) throws a TypeError or RangeError. Each doc comment says which.

Accounts and identities

An account has one or more identities, each a kind and a subject: google and Google's sub, email and the normalized address, passkey and the credential id. An identity may carry an address and says whether it is confirmed. Addresses are compared normalized (normalizeEmail): NFC, trimmed, lower case, an international domain in its ASCII form. Dots and +tags are not folded (only some providers ignore them) and look-alike letters of another script stay different, so two people are never one.

Whose account a sign-in is, decided in one D1 batch, so two sign-ins at the same moment make one account:

  1. The identity signed in before: its account, also when its address changed at the provider (nothing is merged).
  2. Its address is confirmed and an account holds the same address confirmed: that account, with this identity added (Google joins the account made with a password, and the other way round; a second Google account with the same verified address joins too). When two accounts hold it, the one that held it first.
  3. Otherwise a new account. An identity with no address, or with one that is not confirmed, joins nothing.

A confirmed sign-in removes every unconfirmed claim on its address, in other accounts too, and ends the sessions those claims started. It never joins the account such a claim made: whoever registered an address they do not own chose what is in that account, and would hand it to the owner. So the owner gets an account of their own, the claimant keeps no identity and no session, and claiming an address again and again blocks nobody. An account left with no identity keeps its row (for what the app stores under its id) and loses every session; nothing leads to it again. SELECT id FROM accounts WHERE id NOT IN (SELECT account_id FROM identities) lists them for a cleanup.

Joining by address means that whoever controls a confirmed address can enter the account, as with a password reset by mail. What must not follow an address (a staff role) is pinned to the identity instead: onSignIn gets identity (google and the sub), so another Google account with the same address is told apart.

Hooks

  • onSignIn(event) runs after each sign-in, before the answer: accountId, email and emailVerified (link nothing to an address that is not verified), method, identity, created, session, request. What it throws fails the sign-in: the new session ends and an account that sign-in made is removed, so the next attempt is created again. The session the browser came with has ended by then.
  • me(session, request) adds fields to what me answers; it cannot replace the core's. The core keeps nothing between requests, so one account's fields never reach another.
  • onDeleteAccount(session, request) runs before the account goes; throwing keeps it. Ask for a recent sign-in there if the app wants one (signedInWithin), and remove what the app keeps for the account.
  • rateLimiter counts the attempts to complete a sign-in per network, before anything is verified. Starting a sign-in is not counted: it writes nothing (the challenge lives in the cookie), and a refused attempt writes nothing either, so a flood grows no table. A method with routes of its own limits them itself (a password: per address).

Methods

A method turns a completed sign-in into one verified identity, or a refusal code:

interface SignInMethod {
  id: string                    // the session's `method`, and the path its routes are under
  configured: boolean           // false while its client id or secret is missing
  begin?(input): Record<string, unknown>          // what the page needs to leave for the provider
  complete?(input): Promise<MethodOutcome>        // { ok: true, identity } | { ok: false, code, status?, retry?, data? }
  routes?(request, context): Promise<Response | null>   // anything else under <base>/<id>/
  openPosts?: string[]          // paths under <base>/<id>/ whose POSTs the core lets through unchecked
}

The core does for every method what must not differ between them:

  • The challenge. start puts a random secret into the cookie __Host-sign-in (ten minutes) and hands the method its SHA-256 as challenge: the OpenID Connect nonce, the OAuth state, or both. The page never sees the secret, so what travels to the provider and back is useless in another browser. complete gets the challenge of the cookie this browser presents; it is bound to the method that started it, expires by the server's clock, and works once (a table of spent challenges), also for two requests at the same moment. Every answer but "try again" takes the cookie away; a refusal writes nothing, so a method whose proof can be guessed counts attempts itself.
  • The checks on a method's own routes. A GET or HEAD passes and must change nothing. Anything else must be a POST from this origin with a JSON body, which the core reads into context.body; a method cannot forget it. A path a method names in openPosts is let through as it is, for an answer that is no JSON or that another site must be able to post (Google's callback page hands the token on as a form; Apple posts its answer).
  • Who may start a session. context.signIn works only on a request bound to this browser: a write the core checked, or one whose challenge context.takeChallenge() just took. On a plain GET or an open POST it answers 403, so a link or a form another site makes the browser follow signs nobody in as someone else.
  • The return path. begin gets returnPath only when it is a path on this origin (safeReturnPath, which also refuses what names another site once a router has percent-decoded it: /%2F%2Fevil, /%5Cevil).
  • The join rule above, and the one place a session starts (context.signIn for a method with routes of its own): no method signs anyone in while it is switched off.

./google is the first: begin answers { clientId, nonce }, the page leaves for Google (@codefusion-cc/sign-in/google/browser), Google sends the browser to <base>/google/callback (register it as the redirect URI), and complete verifies the ID token with verifyGoogleIdToken. An address Google did not verify is refused (email_unverified); Google's keys out of reach is a 503 that ends nobody's session and keeps the challenge, so the page sends the same token again.

The login page the callback returns a sign-in to is callback.loginPath: one path (default /login), several, or a function of the callback's request that answers either.

google({ clientId, callback: { loginPath: ['/konto', '/en/konto', '/de/konto'] } })       // a login page per language
google({ clientId, callback: { loginPath: request => new URL(request.url).hostname === 'b2b.example' ? '/partner/login' : '/login' } })

A sign-in returns only to the page it left from (with that page's query, so ?next= survives), only when that page is one of these, and only on the origin it left from. The first is the page the callback links back to when the hand-off fails; a function that puts the visitor's language first gives that link the right page. Every path of a list or a function must be a path on this origin (safeReturnPath): a list that names anything else is a TypeError when google() is called, and what a function answers that is none is dropped, with /login when nothing is left. Google's token arrives in the fragment of the page named, and a link someone else made can send a person's token to any page named here, with a query of their choosing (never to another page or origin), so:

  • every page named is a login page that calls withGoogle.finish() as it loads, which takes the token out of the address at once and accepts it only for a sign-in this tab started; on a page that does not, the token stays in the address for every script of that page to read;
  • no page named shows what other people wrote or runs a script the app does not trust;
  • a function picks among the app's own login pages (by the request's hostname or language) and never answers a path it took from the request: its query, a header, a cookie.

Google's addresses are fixed: the method verifies with Google's keys and for Google's issuer, and nothing a request carries, and no variable, changes that. keys is for another cache or fetch in front of Google's own keys; whoever holds a key of the set it names signs anyone in, so it is never made from a request or a variable. The fake Google of the tests is in ./testing (below).

How the methods to come sit on it

None of these is built. Each is a later entry of this package and touches nothing in the core.

  • Apple (./apple): OpenID Connect like Google, with response_mode=form_post. Apple's POST to <base>/apple/callback comes from another site, so the method names it in openPosts, and the browser sends no SameSite=Lax cookie with it: the method's routes answers a page that hands id_token and state to the app's login page, which posts them to POST <base>/apple with the challenge cookie, as Google's callback does. complete verifies the token with nonce = challenge. Apple sends the name only with the first sign-in: the identity carries it when present, and the account keeps the name it has when a later sign-in has none. A relay address is an address like any other: it is confirmed and joins only an account that holds the same one.
  • Facebook (./facebook): an authorization code and no ID token. begin answers the address to go to with state = challenge; complete compares body.state with the challenge, exchanges body.code with the app's secret and asks for the profile. The address may be missing, and Facebook does not say it is verified: { kind: 'facebook', subject: id, email, emailConfirmed: false } gets an account of its own and joins nothing. Facebook out of reach is { ok: false, code: 'sign_in_unavailable', retry: true }.
  • A password (./password): no begin or complete, only routes (register, sign in, confirm the address, reset), which read context.body (the core has checked the origin and the JSON) and end in context.signIn. The link in the confirmation mail is a GET, which signs nobody in: it answers a page whose button posts the token. The identity is { kind: 'email', subject: address, email: address, emailConfirmed }: the address is the identity, the password is one credential on it, kept in the entry's own table (the hash, never here). The session's method is password. Registering signs in with emailConfirmed: false (an account of its own until the address is confirmed); confirming signs in with true. The rule for that slice: when another account already holds the address confirmed (the person signed in with Google meanwhile), the password credential attaches to that account, never to a second one: the email identity moves to the holder in the same batch (account_id in the upsert of statement 2), the account it registered with is left without an identity, and no two accounts hold one confirmed address through this path. Today's core does not move it yet: nothing confirms an email identity before that slice.
  • A code or link by mail (./email-code): another proof of the same email identity, so someone who registered with a password and later signs in by code is the same account without a join. A code proves the address: emailConfirmed: true. Its routes use context.issueChallenge() and context.takeChallenge(), so a code works only in the browser that asked for it, once; a link opened in that browser may sign in on its GET for the same reason, and one opened elsewhere gets a page that posts. It counts wrong codes per address itself. The session's method is email-code.
  • Passkeys (./passkey, on @codefusion-cc/passkeys): the identity is { kind: 'passkey', subject: credential id, emailConfirmed: false }. It carries no address and never joins by one. Its steps (options, verify) are routes with the WebAuthn challenge from context.issueChallenge(), kept by the server side of the cookie, single use and bound to the browser. A passkey is added only by someone signed in: the add step reads context.session() and writes the identity for that account (the one statement this needs, link, comes with that entry). An app that wants a strong sign-in asks sessions.require(request, { signedInWithinSeconds, methods: ['passkey', 'google'] }).

dieselpaul moves onto this with the password entry: its users become accounts (ids are text here, so 42 stays '42', and newAccountId numbers new ones the same way), each user an email identity with confirmed = confirmed_at IS NOT NULL, its PBKDF2 hashes go to the password entry's table with their parameters and are verified as they are, and staff passkeys become passkey identities. Staff are not given shorter sessions: the panel asks sessions.require(request, { signedInWithinSeconds: 7 * 86_400, methods: [...] }) on every request, which holds for whoever is staff today without a lifetime per account. That slice also adds an onSignOut hook (dieselpaul and Magnetar end other things at sign-out) and decides whether a valid dp_session cookie (no __Host- prefix) is adopted once into a new session or people sign in once after the move.

Switching methods on and off

The table sign_in_methods holds one row per method someone switched (id, enabled, changed_at, changed_by); a method nobody switched is on. A method is offered only when the app registers it, it is configured, and it is enabled.

await signIn.methods.list()
// [{ id: 'google', configured: true, enabled: true, offered: true, changedAt: null, changedBy: null }, …]
await signIn.methods.setEnabled('password', false, staff.account.email)
// { ok: true, enabled: false } | { ok: false, problem: 'unknown_method' | 'last_method' | 'unavailable' }
  • The last offered method cannot be switched off (last_method), also when two switches arrive at the same moment: the check and the write are one statement.
  • Off deletes no identity and ends no session. It stops new sign-ins: start and complete answer 403 method_disabled, also for a sign-in that started before the switch.
  • An id the app does not register is refused (unknown_method), and a row for one (a later version's, or written by hand) offers nothing: a setting never turns on a method the app does not carry.
  • The routes read the table once per methodsCacheSeconds per isolate (default 30); the isolate that made the switch follows at once, the others within that time. list() always reads.

To put the switch into an app: a staff route that answers signIn.methods.list() and one that calls setEnabled(id, on, <who>) after sessions.require(request, { signedInWithinSeconds }) and the app's own role check; the same two calls behind a console admin action. The package ships no screen and no route for it, since who may switch is the app's to say.

On the page

import { createSessionStore } from '@codefusion-cc/sign-in/browser'
import { googleSignIn } from '@codefusion-cc/sign-in/google/browser'
import { GoogleButton, useSession, useSignInMethods } from '@codefusion-cc/sign-in/react'

export const session = createSessionStore<{ role: string }>()   // one per page
export const withGoogle = googleSignIn(session)

function Account() {
  const { status, me } = useSession(session)        // loading | signed-in | signed-out | expired | unavailable
  const offered = useSignInMethods(session)         // { status: 'ready', methods: ['google', …] }: a button for each the app knows
  if (offered.status === 'unavailable') …           // offered.problem ('network', 'unavailable', …) and the Worker's offered.code
  return offered.methods.includes('google') && (
    <GoogleButton onClick={() => withGoogle.start({ returnPath: location.pathname })}>{t('Continue with Google')}</GoogleButton>
  )
}

// The Google button, and once on the login page when the browser is back:
await withGoogle.start({ returnPath: location.pathname })
const result = await withGoogle.finish()            // null, { ok: true } or { ok: false, problem }
  • One me request however many components ask: load() joins the question in flight. refresh() asks again now, so its answer is never older than the call: after a request of the app's own that changed what me says, refresh() shows the change, also while an earlier question is still out (that one's late answer is dropped). refresh() calls made in the same turn, next to the page's first load() too, share one request; a failure is not kept. A page that only wants the state calls load(), which never asks twice.
  • Which methods are offered is loading, ready (the list, which may be empty) or unavailable, and unavailable says why: problem as for any action (network for no answer, unavailable for a 503, rate-limited, server for an answer that is no list) and code, the Worker's own (sessions_unavailable), when it sent one.
  • session.fetch is fetch that reports a 401; reportUnauthorized() does the same for the app's own client. Many 401s are one question. A session that ended shows expired and keeps me, so the page keeps what the person typed and asks them to sign in again; it never throws them out by itself.
  • Answers that arrive out of order cannot undo a newer one, a sign-in or a sign-out.
  • A sign-out in one tab signs the others out (BroadcastChannel); a sign-in makes them ask.
  • A double click on any action is one request. Failures are results (problem: network, unavailable, disabled, rate-limited, …), never exceptions; the app words them.
  • A tab that comes back into view asks again when its answer is older than five minutes.

The Google button

GoogleButton is a real <button type="button"> drawn to Google's branding rules for a custom button: Google's "G" in its own colours (inline SVG, nothing is fetched), the fill, line and text colour of Google's light, dark or neutral theme, 40 pixels high at least, the label in Roboto Medium 14/20 where the page has that font.

<GoogleButton onClick={() => withGoogle.start({ returnPath })} theme={appTheme} className="w-full">
  {t('Continue with Google')}
</GoogleButton>
  • The label is the app's, in its language; Google allows "Sign in with Google", "Sign up with Google" and "Continue with Google". It is the button's accessible name; the logo and the spinner are hidden from a screen reader.
  • Pending by itself. While the promise onClick returned is not settled the button shows a spinner, says it is busy (aria-busy, aria-disabled), stays focusable and takes no second press by mouse, Enter or Space. pending holds it there for longer (the browser is leaving for Google); disabled is the native attribute. A rejection frees the button and stays the app's to handle.
  • theme is auto by default: light or dark by the system's colour scheme. An app with a theme switch of its own passes the theme it shows. shape="pill" is Google's other shape.
  • Styling. Every rule has no specificity, so a class of the app's sets the width, the height or the corners wherever its stylesheet stands; the colours and the logo are Google's. The rules are added to the document's head once (nonce for a Content-Security-Policy that asks for one); GOOGLE_BUTTON_CSS is the same text for a page rendered on a server or a policy that admits styles by hash.
  • Every other attribute of a <button> passes through (type="submit", aria-describedby, style).

Migrations

The tables come as one migration, generated for the prefix the app uses:

npx codefusion-sign-in-migrations migrations/0000_sign_in          # writes; --check only reports
npx codefusion-sign-in-migrations migrations/0000_sign_in --prefix=auth_

accounts, identities, sessions, sign_in_challenges and sign_in_methods. Every query of the package is by a primary key or an index: a session by its hash, an account's sessions and identities by account_id, the join by identities(email), pruning by expires_at.

Testing

import { resetSignInTables, testBrowser, testGoogle, testGoogleIssuer, testGoogleSignIn, testSession } from '@codefusion-cc/sign-in/testing'

await resetSignInTables(env.DB)
const issuer = await testGoogleIssuer()
const signIn = createSignIn({ db: env.DB, methods: [testGoogle(issuer)] })   // google() against the fake Google
const browser = testBrowser(request => signIn.handle(request))
await testGoogleSignIn(browser, issuer, { email: '[email protected]' })   // through the real routes
await testSession(signIn.sessions, { browser, identity: { email: '[email protected]' } })   // no provider at all

testGoogle(issuer, { callback, hosts }) is the one way to point the Google method at another Google, and it is in this entry on purpose: whoever holds the fake's key signs in as anyone, so no production entry imports it, and it takes the issuer as an object a test made, not a name a variable or a request could carry. An app whose end-to-end tests run its whole Worker gives them an entry of their own that the deployment never builds, and registers testGoogle there in place of google():

// worker/test-entry.ts, named by the test's wrangler configuration only
const issuer = await testGoogleIssuer({ clientId: 'test-client' })
export default app({ google: testGoogle(issuer, { callback: { loginPath: ['/konto', '/en/konto'] } }) })

Should it reach a deployment all the same, it signs nobody in there: it works only on requests whose own address is this machine's (localhost, 127.0.0.1, [::1], *.localhost), a .test name, or a hostname the test lists in hosts, and answers 503 method_not_configured on any other. GET <base>/methods still lists google there (what is configured is not a matter of the request), so the page shows a button that answers not-configured: a test entry that was deployed is seen at the first press.

The host rule is the second guard, and it holds only where the request's address cannot be chosen by whoever sends it. On Workers it is the address the request was routed by, which no header, cookie or body changes. A server that builds the address from the Host header (Node behind a proxy), or a Worker called by another Worker with an address that one makes up, has only the first guard: the testing entry is not in what it deploys. Never list a deployment's hostname in hosts, and keep a fixed signing key (testGoogleIssuer({ key })) out of anything deployed: whoever has it signs in as anyone wherever testGoogle answers.

The page's side still leaves for accounts.google.com: a browser test answers that address itself (Playwright's route) with issuer.authorize(url).

What is next

Not here: mail, passwords, codes, WebAuthn, Apple, Facebook, a settings screen and a console action for the switches, merging two accounts that both exist, and roles (the app adds a role to me with the me hook and checks it in its own routes). Decided for the slices that follow:

  • The fake Google's account chooser: startGoogleSignIn of @codefusion-cc/google-sign-in/browser has Google's address fixed, so a browser test intercepts it. A chooser address a test can name there (in code, as testGoogle is), and handleGoogleSignIn taking several login pages itself in place of this package asking it once per page, come with that package's next release.
  • Password (./password): a confirmed password address that another account already holds confirmed attaches to that account (see above); attempts are limited per address and per network by the entry itself.
  • Staff: no session lifetime per account. A panel asks for a sign-in within 7 days by the methods it accepts (signedInWithinSeconds and methods of sessions.require), and pins a role to onSignIn's identity.
  • dieselpaul's move: an onSignOut hook, and the decision on adopting its dp_session cookie.
  • sessions.link(accountId, identity) for adding a passkey or a password to a signed-in account comes with the first entry that needs it.
  • Shared D1 types: Database and Statement are declared here, in commerce and in mail, and the migrations command repeats commerce's. One package for both (@codefusion-cc/d1: the structural types, named parameters, the migration file writer) once no open pull request edits the three.