@akedly/shield

中文文档
1.1.0发布于 2026/08/04MITTypeScriptESM + CJS零依赖近 30 天下载 3828

Client-side PoW solver, Turnstile helper, and hosted passkey ceremony launcher for Akedly Shield V1.2

原始使用文档
文档目录19 节
  1. Installation
  2. Quick Start
  3. API Reference
  4. solvePow(challenge, difficulty, options?)
  5. new AkedlyShield(options?)
  6. getTurnstileToken(siteKey, options?)
  7. renderTurnstile(siteKey, container, options?)
  8. openPasskeyCeremony(token, options?)
  9. isPasskeySupported()
  10. Platform Support
  11. Web Worker Behavior
  12. Algorithm
  13. Full Integration Example
  14. Passkeys (V1.2)
  15. Recipe 1 — Enroll a passkey right after a successful OTP ve…
  16. Recipe 2 — Authenticate a returning user with their passkey
  17. Verify the result (seamless, no polling)
  18. Recipe 3 — Fall back to OTP on cancel / failure / missing p…
  19. Related Packages

@akedly/shield

Client-side Proof-of-Work solver, Turnstile helper, and hosted passkey ceremony launcher for Akedly Shield V1.2.

Installation

npm install @akedly/shield

Or via CDN:

<script src="https://unpkg.com/@akedly/shield/dist/akedly-shield.min.js"></script>

Quick Start

import { solvePow } from '@akedly/shield';

// 1. Get challenge from your server
const res = await fetch('/api/v1.2/transactions/challenge?APIKey=...&pipelineID=...');
const { data } = await res.json();

// 2. Solve PoW
const { nonce } = await solvePow(data.challenge, data.difficulty);

// 3. Send OTP with solution
await fetch('/api/v1.2/transactions/send', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    APIKey: '...',
    pipelineID: '...',
    verificationAddress: { phoneNumber: '+201234567890' },
    powSolution: { challengeToken: data.challengeToken, nonce }
  })
});

API Reference

solvePow(challenge, difficulty, options?)

Convenience function that creates a solver, finds the nonce, and cleans up.

  • challenge — 64-char hex string from the server
  • difficulty — integer (number of leading hex zeros required)
  • options.onProgress(hashesChecked) — progress callback
  • options.useWorker — true, false, or 'auto' (default: 'auto')
  • Returns Promise<{ nonce: number }>

new AkedlyShield(options?)

Reusable solver instance.

  • options.useWorker — true, false, or 'auto' (default: 'auto')

.solve(challenge, difficulty, options?)

Same as solvePow but reuses the instance.

.terminate()

Kills the active Web Worker and releases the Blob URL.

getTurnstileToken(siteKey, options?)

Browser-only. Creates a hidden Turnstile widget, resolves with the token, and cleans up.

  • siteKey — Cloudflare Turnstile site key
  • options.theme — 'light', 'dark', or 'auto'
  • options.size — 'normal' or 'compact'
  • Returns Promise<string>

renderTurnstile(siteKey, container, options?)

Renders a Turnstile widget into a DOM element.

  • container — DOM element to render into
  • Returns Promise<string> (token)

openPasskeyCeremony(token, options?)

Browser-only. Opens the hosted V1.2 passkey ceremony (auth.akedly.io/pk) as a popup and resolves with the result the page relays back. Use it for both enroll (pass the enrollmentToken from a successful /verify) and auth (pass the ceremonyToken your backend gets from POST /auth-options).

Must be called inside the direct user-gesture handler (e.g. the click handler) or the browser will block the popup. If you need a backend round-trip to mint the token after the click (the auth case — /auth-options), open a blank window synchronously in the handler and hand it over via options.popup (see Recipe 2).

  • token — a ceremony token minted by your backend (enrollmentToken to enroll, ceremonyToken to authenticate)
  • options.ceremonyOrigin — override the hosted origin (default https://auth.akedly.io)
  • options.popup — a Window you opened synchronously in the click; the SDK navigates it instead of opening its own (use when the token is fetched after the gesture)
  • options.onResult(result) — side-channel callback (same payload as the resolved value)
  • options.onTelemetry(evt) — fire-and-forget funnel events (passkey_opened, passkey_verified, passkey_cancelled, …)
  • options.windowFeatures / options.windowName — window.open overrides
  • options.timeoutMs — auto-resolve with reason: 'timeout' after N ms (default 0 = no timeout)
  • Returns Promise<PasskeyCeremonyResult>:
{
  verified: boolean;        // true only on a completed, successful ceremony
  supported: boolean;       // false only when WebAuthn is unavailable
  purpose: string | null;   // 'enroll' | 'auth'
  transactionId: string | null; // the passkey requestID
  resultToken: string | null;   // signed proof of a verified outcome — verify on YOUR backend
  code: string | null;
  reason: string | null;    // null when verified; else 'closed' | 'popup_blocked'
                            //   | 'unsupported' | 'timeout' | 'no_proof'
                            //   | 'failed' | <server code>
                            // NOTE: /pk does not send a server `code` today, so `code` is
                            //   always null and `reason` is only ever one of the local
                            //   values above. Reserved in the contract, not a live branch.
}

⚠️ Don't trust the raw verified flag — verify the resultToken. The message itself is a client signal and could be forged. But every verified outcome carries a resultToken: a compact value HMAC-signed with your account API key, so your backend confirms it offline — synchronously, with no polling and no round-trip to Akedly. See Verify the result below. (Two other channels also exist: if your pipeline sets backendCallbackURL, Akedly also fires that server-to-server callback when an authentication reaches /auth-verify — you do not opt into it, and because it is unsigned it is a delivery signal rather than proof. It does not fire for enrollment, nor for outcomes that never reach the server at all — closed, popup_blocked, unsupported, timeout — so do not wait on a webhook for those. There is also a GET /api/v1.2/transactions/passkey/result?token=<ceremonyToken> lookup — auth ceremonies only.)

The promise never rejects on a normal outcome (it resolves with a reason so you can fall back to OTP); it rejects only on a programming error (missing token).

isPasskeySupported()

Synchronous boolean — true when the browser exposes window.PublicKeyCredential. Use it to skip the passkey offer entirely on unsupported browsers (fall straight through to OTP).

Platform Support

PlatformPoW SolverTurnstile
Browser (Worker)Web Worker (off-thread)getTurnstileToken()
Browser (no Worker)Batched main-threadgetTurnstileToken()
React NativeBatched main-threadUse bridge page
Node.jsSync with native cryptoN/A

Web Worker Behavior

When useWorker is 'auto' (default), the library:

  1. Checks if Worker, Blob, and URL.createObjectURL are available
  2. If yes, bundles the solver code into a Blob URL Worker (no separate file needed)
  3. If no (e.g., React Native), falls back to batched main-thread solving with setTimeout(fn, 0) between batches to keep the UI responsive

Algorithm

hash = SHA256(challenge + ":" + String(nonce))   // hex digest
valid = hash.startsWith("0".repeat(difficulty))   // leading hex zeros

The solver increments nonce from 0 until a valid hash is found.

Full Integration Example

import { solvePow, getTurnstileToken } from '@akedly/shield';

async function sendOTP(phoneNumber, apiKey, pipelineID) {
  // Step 1: Get challenge
  const challengeRes = await fetch(
    `https://api.akedly.io/api/v1.2/transactions/challenge?APIKey=${apiKey}&pipelineID=${pipelineID}`
  );
  const { data } = await challengeRes.json();

  // Step 2: Solve PoW (if required)
  let powSolution;
  if (data.challengeRequired) {
    const { nonce } = await solvePow(data.challenge, data.difficulty, {
      onProgress: (checked) => console.log(`${checked} hashes checked...`)
    });
    powSolution = { challengeToken: data.challengeToken, nonce };
  }

  // Step 3: Get Turnstile token (if required)
  let turnstileToken;
  if (data.turnstile?.required) {
    turnstileToken = await getTurnstileToken(data.turnstile.siteKey);
  }

  // Step 4: Send OTP
  const sendRes = await fetch('https://api.akedly.io/api/v1.2/transactions/send', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      APIKey: apiKey,
      pipelineID,
      verificationAddress: { phoneNumber },
      powSolution,
      turnstileToken
    })
  });

  return sendRes.json();
}

Passkeys (V1.2)

openPasskeyCeremony is a stateless launcher — it never calls /challenge, /send, or /verify. You own those, exactly as you do today; passkeys add three customer-driven recipes on top. The hosted auth.akedly.io/pk page runs the WebAuthn ceremony (fully branded from your pipeline) and relays the result back. Always confirm server-side.

Recipe 1 — Enroll a passkey right after a successful OTP verify

A V1.2 /verify success now returns an additive enrollmentToken (valid ~2 min, single use). Offer enrollment immediately:

⚠️ Pass returnTarget to /verify if you want a verifiable enroll result. The hosted page relays a resultToken only to a server-signed return target, and the enrollment token carries one only if you asked for it. Omit it and enrollment still succeeds — the passkey is created and works — but the ceremony resolves verified:false, reason:"no_proof", because every Akedly SDK refuses to call an unproven result verified. So do not gate your "passkey enabled!" UI on result.verified unless you passed returnTarget; treat the enroll result as advisory otherwise, and let the next successful sign-in be the proof.

import { openPasskeyCeremony, isPasskeySupported } from '@akedly/shield';

// ...your normal challenge -> (PoW/Turnstile) -> send -> verify flow...
const verify = await verifyOtp(requestID, otp); // your existing call
if (verify.data?.verified && verify.data?.enrollmentToken && isPasskeySupported()) {
  // MUST run in the click handler of an "Enable Face ID / fingerprint" button.
  const result = await openPasskeyCeremony(verify.data.enrollmentToken, {
    onTelemetry: (e) => analytics.track(e.event, e.props)
  });
  if (result.verified) {
    // Enrollment has no /result endpoint — the passkey now simply exists and is proven on
    // the user's next sign-in. Update your own UI / record-keeping as you see fit.
    await fetch('/api/passkey-enrolled', { method: 'POST', body: JSON.stringify({ transactionId: result.transactionId }) });
  }
  // result.verified === false (closed / unsupported / ...) => just don't show "enabled"; harmless.
}

Recipe 2 — Authenticate a returning user with their passkey

Your backend starts the ceremony by calling POST /auth-options (it carries the secret API key and clears the PoW/Turnstile gate), then hands the ceremonyToken to the browser:

⚠️ Web: include returnTarget.origin in the /auth-options call. The hosted page will never relay a signed resultToken to an unknown web origin — omit returnTarget and the popup posts to "*", so the gateway strips the token (result.resultToken arrives null and offline verification can't complete). Pass your site origin so the token is relayed. The enroll Enroll needs one too — corrected 2026-07-28. This line used to say "the enroll flow needs no token, and the native return-URL / bridge flows carry it without a returnTarget." Both halves were wrong. Enrollment's proof is relayed under exactly the same rule, so pass returnTarget to /verify (Recipe 1) or the enroll result arrives unproven. And of the native paths, only the WebView bridge carries the token without a returnTarget; return-URL redirects are stripped the same way on Android and Flutter.

// --- your backend ---
// POST https://api.akedly.io/api/v1.2/transactions/passkey/auth-options
//   { APIKey, pipelineID, verificationAddress: { phoneNumber },
//     returnTarget: { origin: 'https://your-site.example' }, // <-- REQUIRED for the web resultToken
//     powSolution?, turnstileToken? }
// -> { data: { ceremonyToken, requestID } }   (or a "no passkey" code => use OTP)

// --- your frontend (sign-in click handler) ---
// The token needs a backend round-trip, so open a blank popup NOW (in the gesture) and
// hand it to the SDK once the token is ready — a window.open after the await is blocked.
const popup = window.open('about:blank', 'akedly_passkey', 'width=480,height=680');
const { ceremonyToken } = await startAuth(phoneNumber); // your backend -> /auth-options
if (!ceremonyToken) { popup?.close(); return fallbackToOtp(); } // no passkey on file

const result = await openPasskeyCeremony(ceremonyToken, { popup });
if (result.verified) {
  // Seamless + synchronous: hand the proof token to YOUR backend, which verifies it
  // offline (no poll, no round-trip to Akedly) and creates the session. See "Verify the result".
  await completeSignIn(result.resultToken);
} else {
  await fallbackToOtp(); // Recipe 3
}

Verify the result (seamless, no polling)

Every verified ceremony resolves a resultToken: a compact, signed proof of the outcome. Forward it from your frontend to your backend; verify it there offline with your account API key — no call back to Akedly, no webhook to receive.

pkrt1.<base64url(payload)>.<base64url(hmac)>
   payload = { v, purpose, transactionId, pipelineId, verified, iat, exp }
   hmac    = HMAC_SHA256("pkrt1." + base64url(payload),  YOUR_API_KEY)
// --- your backend --- (Node; ~12 lines, zero deps)
import crypto from 'node:crypto';

export function verifyAkedlyResult(token, apiKey) {
  if (!apiKey) return null;                                       // fail closed on an empty key
  if (typeof token !== 'string' || !token.startsWith('pkrt1.')) return null;
  const parts = token.slice('pkrt1.'.length).split('.');
  if (parts.length !== 2) return null;                         // exactly two segments
  const [data, sig] = parts;
  const b64u = /^[A-Za-z0-9_-]+$/;                             // strict unpadded base64url
  if (!b64u.test(data) || !b64u.test(sig)) return null;
  // canonical form only — a slack-bit alias decodes to the same bytes and would bypass
  // a string-keyed single-use check
  if (Buffer.from(data, 'base64url').toString('base64url') !== data) return null;
  if (Buffer.from(sig, 'base64url').toString('base64url') !== sig) return null;
  const expected = crypto.createHmac('sha256', apiKey).update('pkrt1.' + data).digest();
  const given = Buffer.from(sig, 'base64url');
  if (expected.length !== given.length || !crypto.timingSafeEqual(expected, given)) return null;
  let payload;
  try { payload = JSON.parse(Buffer.from(data, 'base64url').toString()); } catch (e) { return null; }
  if (!payload.exp || Date.now() > payload.exp) return null;   // expired
  if (payload.verified !== true) return null;                  // only a verified outcome is trustworthy
  return payload; // verified + unexpired — bind transactionId + purpose to the ceremony you started
}

// POST /complete-sign-in  { resultToken }
const claim = verifyAkedlyResult(req.body.resultToken, process.env.AKEDLY_API_KEY);
if (claim?.verified && claim.purpose === 'auth' && claim.transactionId === expectedTxId) {
  createSession(user); // safe: the proof is signed by us, bound to this auth transaction
}

Treat the token like a one-time auth code: it is short-lived (~2 min) and you should accept it once. The old confirmation paths still work if you prefer them (backendCallbackURL, or a GET …/passkey/result?token= lookup).

Recipe 3 — Fall back to OTP on cancel / failure / missing proof

Every non-verified outcome is a reason you can branch on — fall straight through to your existing OTP send + verify:

const result = await openPasskeyCeremony(token);
if (!result.verified) {
  // 'closed' | 'popup_blocked' | 'unsupported' | 'timeout' | 'no_proof' | 'failed'
  // (<server code> is reserved in the contract but /pk never sends one today)
  await runOtpFlow(); // your existing challenge -> send -> verify
}

A working end-to-end demo (Node backend driving the real V1.2 REST + a React front end that enrolls, authenticates, and falls back) lives in akedly-v12-test-apps/web — these recipes are extracted from it.

版本与发布记录

@akedly/shield 共发布 4 个版本,首次发布于 2026/03/09;过去 12 个月发布了 4 个版本。

版本发布日期距今
1.1.0latest2026/08/043 个月前
1.0.22026/05/046 个月前
1.0.12026/03/097 个月前
1.0.02026/03/097 个月前

关于 @akedly/shield 的常见问题

@akedly/shield 是什么?

Client-side PoW solver, Turnstile helper, and hosted passkey ceremony launcher for Akedly Shield V1.2

如何安装 @akedly/shield?

在项目目录运行 npm install @akedly/shield,或使用 pnpm add / yarn add / bun add。

@akedly/shield 支持 TypeScript 吗?

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

@akedly/shield 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@akedly/shield 可以商用吗?

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

@akedly/shield 的下载量有多少?

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

@akedly/shield 还在维护吗?

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