@estiva-app/identity

中文文档
0.3.0发布于 2026/10/17MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 2418

Estiva ID sign-in: authorization code + PKCE, token storage, silent renewal, sign-out, signing via POST /sign, and the auth-shell state machine. Knows nothing about any app's data layer.

原始使用文档
文档目录6 节
  1. The seam this package exists for
  2. Every difference between the consumers is a parameter
  3. Signing is asking, and the answer must be checked
  4. Renewal is scheduled, not reactive
  5. The prediction this package falsified
  6. Releasing

@estiva-app/identity

Signing in with Estiva ID: authorization code + PKCE, token storage, silent renewal, sign-out, and the auth-shell state machine.

npm install @estiva-app/identity

Nothing from outside the @estiva-app foundation, no data layer, and no UI. CI enforces all three.

The seam this package exists for

In: everything that decides whether somebody is signed in and what to say when they are not.

Not in: any app's data layer. peek-app/src/auth/useEstivaIdAuth.ts is the adapter ConvexProviderWithAuth reads, and it stays in Peek. If it were here, Ship and every future app would inherit a Convex dependency to work around — which is precisely the failure SHA-4's sequencing exists to avoid.

Ship needing no adapter at all is the test that the line held. It hands the token straight to its relay client.

Not in: the shell's rendering. A full-page surface each app wants its own of. Shared is the state machine and the reason vocabulary. That also keeps this package clear of @estiva-app/ui — ADR 0002 §2 names identity depending on ui as the trigger that folds ui back into the foundation repo, and that should be a decision somebody makes rather than one a sign-in screen makes by accident.

Every difference between the consumers is a parameter

The package is a factory, not a module of free functions, because Peek and Ship genuinely differ on every one of these — and hardcoding any would have made the package come out Peek-shaped, which is the whole risk SHA-4 names.

injectedPeekShip
clientIdestiva-peekestiva-ship
redirectUria fixed /auth/callbackits current pathname — Ship routes on location.hash
keyPrefixpeek.estivaIdship.estiva-id
navigatethe one genuinely untestable act, so it is observable
setTimer / clearTimerthe timers scheduleRenewal runs on, injected for the same reason

Where the session lives is the same for every app (PEE-3). storage defaults to localStorage — one session shared by every tab, surviving a tab close — and pendingStore to sessionStorage, because a sign-in starts and ends in one tab. Peek kept a per-tab session until PEE-3, and every new tab paid a ~0.5 s silent /authorize bounce for it. Both stay injectable (a test, a script); a caller that passes only storage gets it for both, as before.

A shared session needs a cross-tab lock on renewal. Every tab's timer fires for the same expiry, and Estiva ID revokes the whole chain when a refresh token is presented twice. lock defaults to the Web Locks API; a tab that waited for it takes the token another tab just renewed instead of spending the old one.

pendingStore exists because wiring the second consumer found the package assuming one store — it was extracted from Peek, which uses one. That is the "a library pulled from one app comes out shaped like that app" failure SHA-4 names, caught by the mechanism SHA-4 prescribes. Worth knowing as evidence that the second-consumer rule earns its keep rather than being ceremony.

An app instantiates once and re-exports its own names, so call sites do not churn:

const client = createEstivaId({
  base: ESTIVA_ID_ORIGIN,
  clientId: 'estiva-peek',
  redirectUri: () => `${window.location.origin}/auth/callback`,
  keyPrefix: 'peek.estivaId',
  navigate: (url) => window.location.assign(url),
})
export const { validToken, beginSignIn, completeSignIn, beginSignOut } = client

Signing is asking, and the answer must be checked

// Peek: one round trip, a token read fresh each time
const event = await signViaEstivaId(unsigned, { base, token, expectedPubkey: myPubkey })

// Ship: a protocol Signer, built once. Pass a READER, not a string, if anything
// else can replace the token — `scheduleRenewal` does.
const signer = estivaIdSigner({
  base,
  pubkey,
  token: () => client.validToken()?.accessToken,
  renew: client.refreshAccessToken,
})

An app publishes as a person without ever seeing their secret. @estiva-app/protocol owns how an event gets its signature; this package owns who is allowed to ask.

/sign signs as the token's subject regardless of what it is handed. So a token belonging to somebody else is not an error — it is HTTP 200 with a valid event authored by that other person. expectedPubkey is a check, not a request, and estivaIdSigner supplies it unconditionally from the pubkey it was built with. There were three copies of this round trip before SHA-4 and only one of them checked; the one that did not was Ship's, through which every write in Ship passes.

A 401 throws SignerTokenExpired; any other refusal throws SignRefused carrying the HTTP status, because consumers classify on it and a message is not an API — 403 means an administrator has to restore the identity, while a 422 policy refusal means something else entirely. And estivaIdSigner renews once and retries. Never twice: a second 401 after a successful renewal is a token the service will not accept, and retrying that is how one refused signature becomes a hot loop. Anything that is not an expiry is rethrown untouched, so a wrong-author refusal never spends a refresh token.

Renewal is scheduled, not reactive

const schedule = client.scheduleRenewal({
  onRenewed: (token) => setSignedIn(true),
  onEnded: () => setSignedIn(false),   // drop to the shell
})
// later, on sign-out or unmount
schedule.cancel()

tokenFrom stores an expiry 30 seconds early on purpose, and scheduleRenewal is what acts on it. Reacting to expiry instead means somebody's next action is what discovers the session is over — a failed publish, a blank list, at best a retry they can feel. Renewing early means they notice nothing, which is the whole point of PEEK-108.

Two renewals must never be in the air at once. A refresh token is single-use and Estiva ID reads a replay as evidence of theft, revoking the whole chain — so a duplicate renewal does not waste a round trip, it signs somebody out of everything. refreshAccessToken coalesces concurrent callers into one request, and it does so for every caller, not only the scheduler: Ship signs the NIP-98 auth event and the content event as two separate POST /sign calls, and a token that expires between them answers 401 to both.

setTimeout is throttled in a background tab. Chrome and Safari clamp a hidden tab's timers to roughly once a minute, and may freeze them outright after five minutes hidden, so a backgrounded tab can miss its renewal window. Since 0.3.0 the schedule also renews when the tab is shown again or the network comes back (onWake, defaulting to visibilitychange and online; read through locally declared globals, so the published types still name none, ADR 0002 §4a). A renewal that gets no answer retries rather than ending the sign-in; only Estiva ID's refusal calls onEnded.

The prediction this package falsified

Before starting, the expectation on the ticket was that identity could not follow ADR 0002 §4a as cleanly as @estiva-app/protocol did — that being browser-shaped throughout, it would need lib: dom, and that the ADR would need amending to say so.

That was wrong, and it is worth saying why rather than quietly not mentioning it:

  • crypto, btoa, TextEncoder, URL, URLSearchParams, fetch and console are declared inside the modules that use them and read inside function bodies.
  • The DOM's Storage became a local KeyValueStore interface. sessionStorage, localStorage and a plain object all satisfy it structurally, and the published declarations name no ambient type.
  • Navigation is a required navigate callback rather than a location reach.

So the package builds with types: [] and no lib: dom, exactly as protocol does, and a test can import it with every browser global deleted. CI asserts both.

The thing that made it possible was not cleverness. It was that a browser API an app must inject is also a browser API a test can observe — the seams §4a forces and the seams a test wants are the same seams. Worth remembering before assuming the next browser-shaped package needs an exemption.

@estiva-app/ui is still the counter-example: it ships .d.ts files naming HTMLButtonElement, and gets away with it only because both its consumers are browser apps.

Releasing

git tag [email protected] && git push origin [email protected]

The first publish is manual and once-ever, because a trusted publisher is configured on a package that already exists — ADR 0002 §4c, §7. Every release after it is a tag.

版本与发布记录

@estiva-app/identity 共发布 9 个版本,首次发布于 2026/08/28;过去 12 个月发布了 9 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.3.0latest2026/10/1727 天前
0.2.32026/10/131 个月前
0.2.22026/09/082 个月前
0.2.12026/09/082 个月前
0.2.02026/09/082 个月前
0.1.32026/09/082 个月前

关于 @estiva-app/identity 的常见问题

@estiva-app/identity 是什么?

Estiva ID sign-in: authorization code + PKCE, token storage, silent renewal, sign-out, signing via POST /sign, and the auth-shell state machine. Knows nothing about any app's data layer.

如何安装 @estiva-app/identity?

在项目目录运行 npm install @estiva-app/identity,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@estiva-app/protocol(>=0.14.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@estiva-app/identity 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@estiva-app/identity 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@estiva-app/identity 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@estiva-app/identity 可以商用吗?

@estiva-app/identity 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@estiva-app/identity 的下载量有多少?

根据 npm 官方下载统计,@estiva-app/identity 在 2026/10/14 – 2026/11/12 这 30 天内被下载 2,418 次,日均约 81 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@estiva-app/identity 还在维护吗?

最新版本 0.3.0 发布于 2026/10/17(27 天前);至今共发布 9 个版本,其中过去 12 个月发布了 9 个;首次发布于 2026/08/28。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。