@opendatalabs/vana-sdk 4.3.1 → 4.4.0-connect-sheet.0-pr.218.295a8da
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -0
- package/dist/auth/web3-signed-builder.cjs.map +1 -1
- package/dist/auth/web3-signed-builder.d.ts +12 -6
- package/dist/auth/web3-signed-builder.js.map +1 -1
- package/dist/auth/web3-signed.cjs +11 -0
- package/dist/auth/web3-signed.cjs.map +1 -1
- package/dist/auth/web3-signed.d.ts +7 -0
- package/dist/auth/web3-signed.js +10 -0
- package/dist/auth/web3-signed.js.map +1 -1
- package/dist/connect/account-origin.cjs +72 -0
- package/dist/connect/account-origin.cjs.map +1 -0
- package/dist/connect/account-origin.d.ts +92 -0
- package/dist/connect/account-origin.js +47 -0
- package/dist/connect/account-origin.js.map +1 -0
- package/dist/connect/account-origin.test.d.ts +1 -0
- package/dist/connect/chrome.cjs +221 -0
- package/dist/connect/chrome.cjs.map +1 -0
- package/dist/connect/chrome.d.ts +40 -0
- package/dist/connect/chrome.js +193 -0
- package/dist/connect/chrome.js.map +1 -0
- package/dist/connect/http-transports.cjs +100 -0
- package/dist/connect/http-transports.cjs.map +1 -0
- package/dist/connect/http-transports.d.ts +77 -0
- package/dist/connect/http-transports.js +75 -0
- package/dist/connect/http-transports.js.map +1 -0
- package/dist/connect/http-transports.test.d.ts +1 -0
- package/dist/connect/launcher.cjs +224 -0
- package/dist/connect/launcher.cjs.map +1 -0
- package/dist/connect/launcher.d.ts +122 -0
- package/dist/connect/launcher.js +210 -0
- package/dist/connect/launcher.js.map +1 -0
- package/dist/connect/launcher.test.d.ts +1 -0
- package/dist/connect/protocol.cjs +244 -0
- package/dist/connect/protocol.cjs.map +1 -0
- package/dist/connect/protocol.d.ts +307 -0
- package/dist/connect/protocol.js +203 -0
- package/dist/connect/protocol.js.map +1 -0
- package/dist/connect/protocol.test.d.ts +1 -0
- package/dist/connect.cjs +55 -0
- package/dist/connect.cjs.map +1 -0
- package/dist/connect.d.ts +35 -0
- package/dist/connect.js +34 -0
- package/dist/connect.js.map +1 -0
- package/dist/direct/connect-flow-sheet.test.d.ts +1 -0
- package/dist/direct/connect-flow.cjs +211 -1
- package/dist/direct/connect-flow.cjs.map +1 -1
- package/dist/direct/connect-flow.d.ts +75 -0
- package/dist/direct/connect-flow.js +213 -1
- package/dist/direct/connect-flow.js.map +1 -1
- package/dist/direct/connect-handler.cjs +493 -0
- package/dist/direct/connect-handler.cjs.map +1 -0
- package/dist/direct/connect-handler.d.ts +241 -0
- package/dist/direct/connect-handler.js +476 -0
- package/dist/direct/connect-handler.js.map +1 -0
- package/dist/direct/connect-handler.test.d.ts +1 -0
- package/dist/direct/controller.cjs +58 -3
- package/dist/direct/controller.cjs.map +1 -1
- package/dist/direct/controller.d.ts +79 -3
- package/dist/direct/controller.js +54 -2
- package/dist/direct/controller.js.map +1 -1
- package/dist/direct/endpoints.cjs +5 -2
- package/dist/direct/endpoints.cjs.map +1 -1
- package/dist/direct/endpoints.js +5 -2
- package/dist/direct/endpoints.js.map +1 -1
- package/dist/direct/request-authorization.cjs +65 -0
- package/dist/direct/request-authorization.cjs.map +1 -0
- package/dist/direct/request-authorization.d.ts +65 -0
- package/dist/direct/request-authorization.js +39 -0
- package/dist/direct/request-authorization.js.map +1 -0
- package/dist/direct/request-authorization.test.d.ts +1 -0
- package/dist/direct/types.cjs.map +1 -1
- package/dist/direct/types.d.ts +6 -0
- package/dist/direct/types.js.map +1 -1
- package/dist/direct/use-direct-vana-connect.cjs +18 -1
- package/dist/direct/use-direct-vana-connect.cjs.map +1 -1
- package/dist/direct/use-direct-vana-connect.js +18 -1
- package/dist/direct/use-direct-vana-connect.js.map +1 -1
- package/dist/direct/use-vana-connect.cjs +84 -0
- package/dist/direct/use-vana-connect.cjs.map +1 -0
- package/dist/direct/use-vana-connect.d.ts +59 -0
- package/dist/direct/use-vana-connect.js +64 -0
- package/dist/direct/use-vana-connect.js.map +1 -0
- package/dist/direct/use-vana-connect.test.d.ts +1 -0
- package/dist/index.browser.js +9 -0
- package/dist/index.browser.js.map +2 -2
- package/dist/index.node.cjs +9 -0
- package/dist/index.node.cjs.map +2 -2
- package/dist/index.node.js +9 -0
- package/dist/index.node.js.map +2 -2
- package/dist/react.cjs +10 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.ts +4 -1
- package/dist/react.js +11 -1
- package/dist/react.js.map +1 -1
- package/dist/server.cjs +44 -2
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.ts +6 -2
- package/dist/server.js +49 -3
- package/dist/server.js.map +1 -1
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -457,6 +457,137 @@ pass the same explicit `idempotencyKey` on the retry to avoid a duplicate DCR.
|
|
|
457
457
|
`react` is an optional peer dependency. The underlying
|
|
458
458
|
`createDirectConnectFlow` store is also exported for non-React frontends.
|
|
459
459
|
|
|
460
|
+
### Connect in Vana's in-page sheet
|
|
461
|
+
|
|
462
|
+
Instead of a Vana tab, the person can approve in a sheet Vana draws over your
|
|
463
|
+
page (it signs them in, shows the request, and finishes inside the sheet). The
|
|
464
|
+
sheet is `presentation: "sheet"` of the same flow: the request, the status poll
|
|
465
|
+
and the read are unchanged. The integration is two files.
|
|
466
|
+
|
|
467
|
+
Backend: one catch-all route serves `config`, `request`, `status` and `data`,
|
|
468
|
+
binding each request to the browser that created it.
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
// app/api/vana/[action]/route.ts
|
|
472
|
+
import { createVanaConnectHandler } from "@opendatalabs/vana-sdk/server";
|
|
473
|
+
import { vana } from "@/lib/vana"; // your createDirectDataController(...)
|
|
474
|
+
|
|
475
|
+
const handler = createVanaConnectHandler({
|
|
476
|
+
controller: vana,
|
|
477
|
+
returnUrl: `${process.env.APP_URL}/connect/return`,
|
|
478
|
+
clientId: "my-app", // the embed client id Vana Account registered for your origin
|
|
479
|
+
// Recommended in production: only your signed-in users create requests.
|
|
480
|
+
session: async (request) => (await getSession(request))?.userId ?? null,
|
|
481
|
+
});
|
|
482
|
+
export { handler as GET, handler as POST };
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
The handler reports the Vana Account origin from the controller's `env`
|
|
486
|
+
(`account.vana.org` for `production`, `account-dev.vana.org` for `dev`), so a
|
|
487
|
+
dev app never frames production.
|
|
488
|
+
|
|
489
|
+
Frontend:
|
|
490
|
+
|
|
491
|
+
```tsx
|
|
492
|
+
"use client";
|
|
493
|
+
import { useVanaConnect } from "@opendatalabs/vana-sdk/react";
|
|
494
|
+
|
|
495
|
+
export function ConnectButton() {
|
|
496
|
+
const { state, start } = useVanaConnect<MyData>({ endpoint: "/api/vana" });
|
|
497
|
+
if (state.type === "done") return <Result data={state.result.data} />;
|
|
498
|
+
return (
|
|
499
|
+
<>
|
|
500
|
+
<button
|
|
501
|
+
disabled={
|
|
502
|
+
state.type === "creating" || state.type === "awaiting_approval"
|
|
503
|
+
}
|
|
504
|
+
onClick={start}
|
|
505
|
+
>
|
|
506
|
+
Connect with Vana
|
|
507
|
+
</button>
|
|
508
|
+
{state.type === "awaiting_approval" && state.popupBlocked && (
|
|
509
|
+
// The sheet could not run here (state.sheetUnavailable says why).
|
|
510
|
+
<a href={state.request.approvalUrl} target="_blank" rel="noreferrer">
|
|
511
|
+
Open Vana
|
|
512
|
+
</a>
|
|
513
|
+
)}
|
|
514
|
+
</>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
- While the sheet is up, `state.sheet` reports `completion` (`in_browser` or
|
|
520
|
+
`remote_collect`) once the person approves, coarse `progress`
|
|
521
|
+
(`collecting`, `writing`), and any `error` it is showing.
|
|
522
|
+
- **Approval is not delivery.** The sheet's `connected` event (and
|
|
523
|
+
`state.sheet.completion`) means the person approved; the data may still be
|
|
524
|
+
collecting. The flow reaches `done` only when your backend's status poll
|
|
525
|
+
says the request is readable and the read succeeds, and `error` when the
|
|
526
|
+
poll reports `denied`, `expired` or `completed` or the read fails. The sheet
|
|
527
|
+
sends no terminal event of its own; its events only make the flow poll
|
|
528
|
+
sooner.
|
|
529
|
+
- Closing the sheet before approving returns to `idle`. Closing it after
|
|
530
|
+
approving keeps polling, since the data may still be readable.
|
|
531
|
+
- If the frame cannot run (`frame_blocked`, `origin_not_registered`,
|
|
532
|
+
`incompatible_version`), the flow falls back to the tab presentation with
|
|
533
|
+
`popupBlocked: true`, so the "Open approval" link above still finishes it.
|
|
534
|
+
`origin_not_registered` arrives at once: the launcher asks Vana
|
|
535
|
+
(`GET <account>/api/embed/connect/client`, no credentials) alongside
|
|
536
|
+
framing. `vana_unavailable` means Vana's own side is down or misconfigured;
|
|
537
|
+
the sheet stays open and says so.
|
|
538
|
+
- The partner reads; it cannot write through the sheet. Pass
|
|
539
|
+
`sheet: { closeAfterDoneMs: null }` to leave the sheet open after the read.
|
|
540
|
+
- If your page sets a CSP, allow the frame and the client check:
|
|
541
|
+
`vanaConnectCsp(env)` from `/server` (or `/connect/protocol`) returns
|
|
542
|
+
`{ "frame-src": [accountOrigin], "connect-src": [accountOrigin] }`. It has
|
|
543
|
+
no default environment.
|
|
544
|
+
- `createVanaConnectHandler` takes `session` to bind requests to your own
|
|
545
|
+
signed-in user (recommended: without it anyone who loads your page can
|
|
546
|
+
create requests as your app), `createOptions` for per-request create input
|
|
547
|
+
(questions, owner), and `read` to replace the default `readApprovedData`.
|
|
548
|
+
Use `toNodeHandler(handler)` for Express or plain `http`.
|
|
549
|
+
- Creating a request is rate limited. The default, `createMemoryRateLimit()`,
|
|
550
|
+
allows 10 creates a minute per client IP and per session, in memory. It is
|
|
551
|
+
not production-grade (each instance counts alone, and the IP comes from
|
|
552
|
+
proxy headers): pass `rateLimit` backed by a shared store, or `false` when
|
|
553
|
+
a limiter already sits in front of the route. A refusal is
|
|
554
|
+
`429 rate_limited` with `Retry-After`.
|
|
555
|
+
|
|
556
|
+
Choosing the Vana Account origin yourself (a custom flow, or preview deploys
|
|
557
|
+
of Vana Account) goes through `resolveVanaAccountOrigin`; never match origins
|
|
558
|
+
with your own pattern. It returns the environment's origin unless overrides
|
|
559
|
+
are switched on for this build and the override is exactly an `https` origin
|
|
560
|
+
on your allowlist:
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
import {
|
|
564
|
+
parseOriginAllowlist,
|
|
565
|
+
resolveVanaAccountOrigin,
|
|
566
|
+
} from "@opendatalabs/vana-sdk/connect";
|
|
567
|
+
|
|
568
|
+
const accountOrigin = resolveVanaAccountOrigin({
|
|
569
|
+
env: "dev",
|
|
570
|
+
override: new URLSearchParams(location.search).get("vana_account"),
|
|
571
|
+
previewOrigins: parseOriginAllowlist(
|
|
572
|
+
process.env.NEXT_PUBLIC_VANA_ACCOUNT_PREVIEW_ORIGINS,
|
|
573
|
+
),
|
|
574
|
+
// From build-time config, never the request. Off in production.
|
|
575
|
+
allowOverride: process.env.NEXT_PUBLIC_DEPLOY_ENV === "preview",
|
|
576
|
+
});
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
`createDirectConnectFlow` and the launcher take that origin (or an `env`)
|
|
580
|
+
explicitly; neither falls back to production.
|
|
581
|
+
|
|
582
|
+
Lower-level pieces: `createDirectConnectFlow(transports, { presentation:
|
|
583
|
+
"sheet", sheet: { clientId, appName, accountOrigin } })` with an
|
|
584
|
+
`authorizeRequest` transport backed by
|
|
585
|
+
`controller.authorizeRequestRead(requestId)`; the launcher itself
|
|
586
|
+
(`@opendatalabs/vana-sdk/connect`); and the zero-dependency message protocol
|
|
587
|
+
(`@opendatalabs/vana-sdk/connect/protocol`). A backend in another language
|
|
588
|
+
implements the [connect wire contract](./src/connect/WIRE_CONTRACT.md), which has test
|
|
589
|
+
vectors.
|
|
590
|
+
|
|
460
591
|
### Test with large sample data
|
|
461
592
|
|
|
462
593
|
When testing with realistic exports, use the public fixture catalog in
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/auth/web3-signed-builder.ts"],"sourcesContent":["/**\n * Builder for Web3Signed Authorization headers.\n *\n * @remarks\n * Ported from `personal-server-ts`\n * (`packages/core/src/signing/request-signer.ts`). The original was wired\n * to a Node-only `ServerAccount` and `node:crypto`. This isomorphic version\n * accepts any `signMessage` callback (viem accounts, wallet clients, etc.)\n * and uses `@noble/hashes` for SHA-256 so it runs in browsers and Workers.\n *\n * Wire format is identical to PS — payload is JSON with sorted keys,\n * base64url-encoded, signed via EIP-191.\n *\n * @category Auth\n */\n\nimport { sha256 } from \"@noble/hashes/sha2\";\nimport { bytesToHex } from \"viem\";\nimport { toBase64 } from \"../utils/encoding\";\n\n/**\n * Sign-message callback compatible with viem `LocalAccount`/`WalletClient`-style\n * signers. Must produce an EIP-191 (`personal_sign`) signature.\n */\nexport type Web3SignedSignFn = (message: string) => Promise<`0x${string}`>;\n\n/** SHA-256 of the empty string — bodyHash for empty bodies. */\nconst EMPTY_BODY_HASH =\n \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\";\n\n/** Default token lifetime (seconds). */\nconst DEFAULT_TTL_SECONDS = 300;\n\n/** Base64url encode bytes (no padding). */\nfunction base64urlEncode(input: Uint8Array): string {\n return toBase64(input)\n .replace(/\\+/g, \"-\")\n .replace(/\\//g, \"_\")\n .replace(/=+$/, \"\");\n}\n\n/** Compute the `sha256:<hex>` bodyHash claim for a request body. */\nexport function computeBodyHash(body: Uint8Array | undefined): string {\n if (!body || body.length === 0) {\n return EMPTY_BODY_HASH;\n }\n const digest = sha256(body);\n return `sha256:${bytesToHex(digest).slice(2)}`;\n}\n\n/**\n * Build a Web3Signed Authorization header value.\n *\n * @returns The full header value (`\"Web3Signed <base64url>.<sig>\"`).\n */\nexport async function buildWeb3SignedHeader(params: {\n /** EIP-191 signer (e.g. viem `account.signMessage`). */\n signMessage: Web3SignedSignFn;\n /** Expected origin (e.g. `\"https://ps.example.com\"`). */\n aud: string;\n /** HTTP method (e.g. `\"GET\"`). */\n method: string;\n /** Request URI/path (e.g. `\"/v1/data/instagram.profile\"`). */\n uri: string;\n /** Optional request body — when present, used to compute `bodyHash`. */\n body?: Uint8Array;\n /** Issued-at (unix seconds). Defaults to now. */\n iat?: number;\n
|
|
1
|
+
{"version":3,"sources":["../../src/auth/web3-signed-builder.ts"],"sourcesContent":["/**\n * Builder for Web3Signed Authorization headers.\n *\n * @remarks\n * Ported from `personal-server-ts`\n * (`packages/core/src/signing/request-signer.ts`). The original was wired\n * to a Node-only `ServerAccount` and `node:crypto`. This isomorphic version\n * accepts any `signMessage` callback (viem accounts, wallet clients, etc.)\n * and uses `@noble/hashes` for SHA-256 so it runs in browsers and Workers.\n *\n * Wire format is identical to PS — payload is JSON with sorted keys,\n * base64url-encoded, signed via EIP-191.\n *\n * @category Auth\n */\n\nimport { sha256 } from \"@noble/hashes/sha2\";\nimport { bytesToHex } from \"viem\";\nimport { toBase64 } from \"../utils/encoding\";\n\n/**\n * Sign-message callback compatible with viem `LocalAccount`/`WalletClient`-style\n * signers. Must produce an EIP-191 (`personal_sign`) signature.\n */\nexport type Web3SignedSignFn = (message: string) => Promise<`0x${string}`>;\n\n/** SHA-256 of the empty string — bodyHash for empty bodies. */\nconst EMPTY_BODY_HASH =\n \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\";\n\n/** Default token lifetime (seconds). */\nconst DEFAULT_TTL_SECONDS = 300;\n\n/** Base64url encode bytes (no padding). */\nfunction base64urlEncode(input: Uint8Array): string {\n return toBase64(input)\n .replace(/\\+/g, \"-\")\n .replace(/\\//g, \"_\")\n .replace(/=+$/, \"\");\n}\n\n/** Compute the `sha256:<hex>` bodyHash claim for a request body. */\nexport function computeBodyHash(body: Uint8Array | undefined): string {\n if (!body || body.length === 0) {\n return EMPTY_BODY_HASH;\n }\n const digest = sha256(body);\n return `sha256:${bytesToHex(digest).slice(2)}`;\n}\n\n/**\n * Build a Web3Signed Authorization header value.\n *\n * @returns The full header value (`\"Web3Signed <base64url>.<sig>\"`).\n */\nexport async function buildWeb3SignedHeader(params: {\n /** EIP-191 signer (e.g. viem `account.signMessage`). */\n signMessage: Web3SignedSignFn;\n /** Expected origin (e.g. `\"https://ps.example.com\"`). */\n aud: string;\n /** HTTP method (e.g. `\"GET\"`). */\n method: string;\n /** Request URI/path (e.g. `\"/v1/data/instagram.profile\"`). */\n uri: string;\n /** Optional request body — when present, used to compute `bodyHash`. */\n body?: Uint8Array;\n /** Issued-at (unix seconds). Defaults to now. */\n iat?: number;\n /**\n * Expiry (unix seconds). Defaults to `iat + 300`. Verifiers reject a window\n * longer than 15 minutes after `iat` (see `verifyWeb3Signed`).\n */\n exp?: number;\n /** Optional grant id, attached as the `grantId` claim. */\n grantId?: string;\n /**\n * Optional uniqueness claim, attached as the `nonce` claim (a uuid is the\n * intended shape; the Personal Server bounds it at 128 characters).\n *\n * @remarks\n * A payload is otherwise fully determined by\n * `{ aud, bodyHash, exp, grantId, iat, method, uri }`, so two identical\n * requests signed inside the same second produce the same bytes. On the\n * routes that keep a replay store (the MCP and write-session handshakes\n * and delegated writes) the Personal Server refuses the second as a\n * replay; with a nonce the replay key becomes `(signer, nonce)` instead,\n * which is what lets a poll loop send the same request twice. The nonce\n * is then single use itself: re-using one is a replay even when the rest\n * of the payload changed. Plain data reads keep no replay store; their\n * exposure is bounded by the lifetime cap.\n */\n nonce?: string;\n /** Pre-computed `bodyHash` claim — overrides `body`. */\n bodyHash?: string;\n}): Promise<string> {\n const now = Math.floor(Date.now() / 1000);\n const iat = params.iat ?? now;\n const exp = params.exp ?? iat + DEFAULT_TTL_SECONDS;\n\n const payload: Record<string, unknown> = {\n aud: params.aud,\n bodyHash: params.bodyHash ?? computeBodyHash(params.body),\n exp,\n iat,\n method: params.method,\n uri: params.uri,\n };\n\n if (params.grantId !== undefined) {\n payload[\"grantId\"] = params.grantId;\n }\n\n // Merged before the sort, so the nonce sits in its alphabetical place like\n // every other claim and the payload stays deterministic.\n if (params.nonce !== undefined) {\n payload[\"nonce\"] = params.nonce;\n }\n\n // Sort keys for deterministic serialization.\n const sortedPayload = Object.keys(payload)\n .sort()\n .reduce<Record<string, unknown>>((acc, key) => {\n acc[key] = payload[key];\n return acc;\n }, {});\n\n const payloadJson = JSON.stringify(sortedPayload);\n const payloadBytes = new TextEncoder().encode(payloadJson);\n const payloadBase64 = base64urlEncode(payloadBytes);\n\n const signature = await params.signMessage(payloadBase64);\n\n return `Web3Signed ${payloadBase64}.${signature}`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgBA,kBAAuB;AACvB,kBAA2B;AAC3B,sBAAyB;AASzB,MAAM,kBACJ;AAGF,MAAM,sBAAsB;AAG5B,SAAS,gBAAgB,OAA2B;AAClD,aAAO,0BAAS,KAAK,EAClB,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,EAAE;AACtB;AAGO,SAAS,gBAAgB,MAAsC;AACpE,MAAI,CAAC,QAAQ,KAAK,WAAW,GAAG;AAC9B,WAAO;AAAA,EACT;AACA,QAAM,aAAS,oBAAO,IAAI;AAC1B,SAAO,cAAU,wBAAW,MAAM,EAAE,MAAM,CAAC,CAAC;AAC9C;AAOA,eAAsB,sBAAsB,QAuCxB;AAClB,QAAM,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACxC,QAAM,MAAM,OAAO,OAAO;AAC1B,QAAM,MAAM,OAAO,OAAO,MAAM;AAEhC,QAAM,UAAmC;AAAA,IACvC,KAAK,OAAO;AAAA,IACZ,UAAU,OAAO,YAAY,gBAAgB,OAAO,IAAI;AAAA,IACxD;AAAA,IACA;AAAA,IACA,QAAQ,OAAO;AAAA,IACf,KAAK,OAAO;AAAA,EACd;AAEA,MAAI,OAAO,YAAY,QAAW;AAChC,YAAQ,SAAS,IAAI,OAAO;AAAA,EAC9B;AAIA,MAAI,OAAO,UAAU,QAAW;AAC9B,YAAQ,OAAO,IAAI,OAAO;AAAA,EAC5B;AAGA,QAAM,gBAAgB,OAAO,KAAK,OAAO,EACtC,KAAK,EACL,OAAgC,CAAC,KAAK,QAAQ;AAC7C,QAAI,GAAG,IAAI,QAAQ,GAAG;AACtB,WAAO;AAAA,EACT,GAAG,CAAC,CAAC;AAEP,QAAM,cAAc,KAAK,UAAU,aAAa;AAChD,QAAM,eAAe,IAAI,YAAY,EAAE,OAAO,WAAW;AACzD,QAAM,gBAAgB,gBAAgB,YAAY;AAElD,QAAM,YAAY,MAAM,OAAO,YAAY,aAAa;AAExD,SAAO,cAAc,aAAa,IAAI,SAAS;AACjD;","names":[]}
|
|
@@ -38,7 +38,10 @@ export declare function buildWeb3SignedHeader(params: {
|
|
|
38
38
|
body?: Uint8Array;
|
|
39
39
|
/** Issued-at (unix seconds). Defaults to now. */
|
|
40
40
|
iat?: number;
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Expiry (unix seconds). Defaults to `iat + 300`. Verifiers reject a window
|
|
43
|
+
* longer than 15 minutes after `iat` (see `verifyWeb3Signed`).
|
|
44
|
+
*/
|
|
42
45
|
exp?: number;
|
|
43
46
|
/** Optional grant id, attached as the `grantId` claim. */
|
|
44
47
|
grantId?: string;
|
|
@@ -49,11 +52,14 @@ export declare function buildWeb3SignedHeader(params: {
|
|
|
49
52
|
* @remarks
|
|
50
53
|
* A payload is otherwise fully determined by
|
|
51
54
|
* `{ aud, bodyHash, exp, grantId, iat, method, uri }`, so two identical
|
|
52
|
-
* requests signed inside the same second produce the same bytes
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
55
|
+
* requests signed inside the same second produce the same bytes. On the
|
|
56
|
+
* routes that keep a replay store (the MCP and write-session handshakes
|
|
57
|
+
* and delegated writes) the Personal Server refuses the second as a
|
|
58
|
+
* replay; with a nonce the replay key becomes `(signer, nonce)` instead,
|
|
59
|
+
* which is what lets a poll loop send the same request twice. The nonce
|
|
60
|
+
* is then single use itself: re-using one is a replay even when the rest
|
|
61
|
+
* of the payload changed. Plain data reads keep no replay store; their
|
|
62
|
+
* exposure is bounded by the lifetime cap.
|
|
57
63
|
*/
|
|
58
64
|
nonce?: string;
|
|
59
65
|
/** Pre-computed `bodyHash` claim — overrides `body`. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/auth/web3-signed-builder.ts"],"sourcesContent":["/**\n * Builder for Web3Signed Authorization headers.\n *\n * @remarks\n * Ported from `personal-server-ts`\n * (`packages/core/src/signing/request-signer.ts`). The original was wired\n * to a Node-only `ServerAccount` and `node:crypto`. This isomorphic version\n * accepts any `signMessage` callback (viem accounts, wallet clients, etc.)\n * and uses `@noble/hashes` for SHA-256 so it runs in browsers and Workers.\n *\n * Wire format is identical to PS — payload is JSON with sorted keys,\n * base64url-encoded, signed via EIP-191.\n *\n * @category Auth\n */\n\nimport { sha256 } from \"@noble/hashes/sha2\";\nimport { bytesToHex } from \"viem\";\nimport { toBase64 } from \"../utils/encoding\";\n\n/**\n * Sign-message callback compatible with viem `LocalAccount`/`WalletClient`-style\n * signers. Must produce an EIP-191 (`personal_sign`) signature.\n */\nexport type Web3SignedSignFn = (message: string) => Promise<`0x${string}`>;\n\n/** SHA-256 of the empty string — bodyHash for empty bodies. */\nconst EMPTY_BODY_HASH =\n \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\";\n\n/** Default token lifetime (seconds). */\nconst DEFAULT_TTL_SECONDS = 300;\n\n/** Base64url encode bytes (no padding). */\nfunction base64urlEncode(input: Uint8Array): string {\n return toBase64(input)\n .replace(/\\+/g, \"-\")\n .replace(/\\//g, \"_\")\n .replace(/=+$/, \"\");\n}\n\n/** Compute the `sha256:<hex>` bodyHash claim for a request body. */\nexport function computeBodyHash(body: Uint8Array | undefined): string {\n if (!body || body.length === 0) {\n return EMPTY_BODY_HASH;\n }\n const digest = sha256(body);\n return `sha256:${bytesToHex(digest).slice(2)}`;\n}\n\n/**\n * Build a Web3Signed Authorization header value.\n *\n * @returns The full header value (`\"Web3Signed <base64url>.<sig>\"`).\n */\nexport async function buildWeb3SignedHeader(params: {\n /** EIP-191 signer (e.g. viem `account.signMessage`). */\n signMessage: Web3SignedSignFn;\n /** Expected origin (e.g. `\"https://ps.example.com\"`). */\n aud: string;\n /** HTTP method (e.g. `\"GET\"`). */\n method: string;\n /** Request URI/path (e.g. `\"/v1/data/instagram.profile\"`). */\n uri: string;\n /** Optional request body — when present, used to compute `bodyHash`. */\n body?: Uint8Array;\n /** Issued-at (unix seconds). Defaults to now. */\n iat?: number;\n
|
|
1
|
+
{"version":3,"sources":["../../src/auth/web3-signed-builder.ts"],"sourcesContent":["/**\n * Builder for Web3Signed Authorization headers.\n *\n * @remarks\n * Ported from `personal-server-ts`\n * (`packages/core/src/signing/request-signer.ts`). The original was wired\n * to a Node-only `ServerAccount` and `node:crypto`. This isomorphic version\n * accepts any `signMessage` callback (viem accounts, wallet clients, etc.)\n * and uses `@noble/hashes` for SHA-256 so it runs in browsers and Workers.\n *\n * Wire format is identical to PS — payload is JSON with sorted keys,\n * base64url-encoded, signed via EIP-191.\n *\n * @category Auth\n */\n\nimport { sha256 } from \"@noble/hashes/sha2\";\nimport { bytesToHex } from \"viem\";\nimport { toBase64 } from \"../utils/encoding\";\n\n/**\n * Sign-message callback compatible with viem `LocalAccount`/`WalletClient`-style\n * signers. Must produce an EIP-191 (`personal_sign`) signature.\n */\nexport type Web3SignedSignFn = (message: string) => Promise<`0x${string}`>;\n\n/** SHA-256 of the empty string — bodyHash for empty bodies. */\nconst EMPTY_BODY_HASH =\n \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\";\n\n/** Default token lifetime (seconds). */\nconst DEFAULT_TTL_SECONDS = 300;\n\n/** Base64url encode bytes (no padding). */\nfunction base64urlEncode(input: Uint8Array): string {\n return toBase64(input)\n .replace(/\\+/g, \"-\")\n .replace(/\\//g, \"_\")\n .replace(/=+$/, \"\");\n}\n\n/** Compute the `sha256:<hex>` bodyHash claim for a request body. */\nexport function computeBodyHash(body: Uint8Array | undefined): string {\n if (!body || body.length === 0) {\n return EMPTY_BODY_HASH;\n }\n const digest = sha256(body);\n return `sha256:${bytesToHex(digest).slice(2)}`;\n}\n\n/**\n * Build a Web3Signed Authorization header value.\n *\n * @returns The full header value (`\"Web3Signed <base64url>.<sig>\"`).\n */\nexport async function buildWeb3SignedHeader(params: {\n /** EIP-191 signer (e.g. viem `account.signMessage`). */\n signMessage: Web3SignedSignFn;\n /** Expected origin (e.g. `\"https://ps.example.com\"`). */\n aud: string;\n /** HTTP method (e.g. `\"GET\"`). */\n method: string;\n /** Request URI/path (e.g. `\"/v1/data/instagram.profile\"`). */\n uri: string;\n /** Optional request body — when present, used to compute `bodyHash`. */\n body?: Uint8Array;\n /** Issued-at (unix seconds). Defaults to now. */\n iat?: number;\n /**\n * Expiry (unix seconds). Defaults to `iat + 300`. Verifiers reject a window\n * longer than 15 minutes after `iat` (see `verifyWeb3Signed`).\n */\n exp?: number;\n /** Optional grant id, attached as the `grantId` claim. */\n grantId?: string;\n /**\n * Optional uniqueness claim, attached as the `nonce` claim (a uuid is the\n * intended shape; the Personal Server bounds it at 128 characters).\n *\n * @remarks\n * A payload is otherwise fully determined by\n * `{ aud, bodyHash, exp, grantId, iat, method, uri }`, so two identical\n * requests signed inside the same second produce the same bytes. On the\n * routes that keep a replay store (the MCP and write-session handshakes\n * and delegated writes) the Personal Server refuses the second as a\n * replay; with a nonce the replay key becomes `(signer, nonce)` instead,\n * which is what lets a poll loop send the same request twice. The nonce\n * is then single use itself: re-using one is a replay even when the rest\n * of the payload changed. Plain data reads keep no replay store; their\n * exposure is bounded by the lifetime cap.\n */\n nonce?: string;\n /** Pre-computed `bodyHash` claim — overrides `body`. */\n bodyHash?: string;\n}): Promise<string> {\n const now = Math.floor(Date.now() / 1000);\n const iat = params.iat ?? now;\n const exp = params.exp ?? iat + DEFAULT_TTL_SECONDS;\n\n const payload: Record<string, unknown> = {\n aud: params.aud,\n bodyHash: params.bodyHash ?? computeBodyHash(params.body),\n exp,\n iat,\n method: params.method,\n uri: params.uri,\n };\n\n if (params.grantId !== undefined) {\n payload[\"grantId\"] = params.grantId;\n }\n\n // Merged before the sort, so the nonce sits in its alphabetical place like\n // every other claim and the payload stays deterministic.\n if (params.nonce !== undefined) {\n payload[\"nonce\"] = params.nonce;\n }\n\n // Sort keys for deterministic serialization.\n const sortedPayload = Object.keys(payload)\n .sort()\n .reduce<Record<string, unknown>>((acc, key) => {\n acc[key] = payload[key];\n return acc;\n }, {});\n\n const payloadJson = JSON.stringify(sortedPayload);\n const payloadBytes = new TextEncoder().encode(payloadJson);\n const payloadBase64 = base64urlEncode(payloadBytes);\n\n const signature = await params.signMessage(payloadBase64);\n\n return `Web3Signed ${payloadBase64}.${signature}`;\n}\n"],"mappings":"AAgBA,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B,SAAS,gBAAgB;AASzB,MAAM,kBACJ;AAGF,MAAM,sBAAsB;AAG5B,SAAS,gBAAgB,OAA2B;AAClD,SAAO,SAAS,KAAK,EAClB,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,EAAE;AACtB;AAGO,SAAS,gBAAgB,MAAsC;AACpE,MAAI,CAAC,QAAQ,KAAK,WAAW,GAAG;AAC9B,WAAO;AAAA,EACT;AACA,QAAM,SAAS,OAAO,IAAI;AAC1B,SAAO,UAAU,WAAW,MAAM,EAAE,MAAM,CAAC,CAAC;AAC9C;AAOA,eAAsB,sBAAsB,QAuCxB;AAClB,QAAM,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACxC,QAAM,MAAM,OAAO,OAAO;AAC1B,QAAM,MAAM,OAAO,OAAO,MAAM;AAEhC,QAAM,UAAmC;AAAA,IACvC,KAAK,OAAO;AAAA,IACZ,UAAU,OAAO,YAAY,gBAAgB,OAAO,IAAI;AAAA,IACxD;AAAA,IACA;AAAA,IACA,QAAQ,OAAO;AAAA,IACf,KAAK,OAAO;AAAA,EACd;AAEA,MAAI,OAAO,YAAY,QAAW;AAChC,YAAQ,SAAS,IAAI,OAAO;AAAA,EAC9B;AAIA,MAAI,OAAO,UAAU,QAAW;AAC9B,YAAQ,OAAO,IAAI,OAAO;AAAA,EAC5B;AAGA,QAAM,gBAAgB,OAAO,KAAK,OAAO,EACtC,KAAK,EACL,OAAgC,CAAC,KAAK,QAAQ;AAC7C,QAAI,GAAG,IAAI,QAAQ,GAAG;AACtB,WAAO;AAAA,EACT,GAAG,CAAC,CAAC;AAEP,QAAM,cAAc,KAAK,UAAU,aAAa;AAChD,QAAM,eAAe,IAAI,YAAY,EAAE,OAAO,WAAW;AACzD,QAAM,gBAAgB,gBAAgB,YAAY;AAElD,QAAM,YAAY,MAAM,OAAO,YAAY,aAAa;AAExD,SAAO,cAAc,aAAa,IAAI,SAAS;AACjD;","names":[]}
|
|
@@ -18,6 +18,7 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
18
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
19
|
var web3_signed_exports = {};
|
|
20
20
|
__export(web3_signed_exports, {
|
|
21
|
+
MAX_WEB3SIGNED_LIFETIME_SECONDS: () => MAX_WEB3SIGNED_LIFETIME_SECONDS,
|
|
21
22
|
parseWeb3SignedHeader: () => parseWeb3SignedHeader,
|
|
22
23
|
verifyWeb3Signed: () => verifyWeb3Signed
|
|
23
24
|
});
|
|
@@ -28,6 +29,7 @@ var import_errors = require("./errors.cjs");
|
|
|
28
29
|
var import_web3_signed_builder = require("./web3-signed-builder");
|
|
29
30
|
const WEB3_SIGNED_PREFIX = "Web3Signed ";
|
|
30
31
|
const CLOCK_SKEW_SECONDS = 60;
|
|
32
|
+
const MAX_WEB3SIGNED_LIFETIME_SECONDS = 15 * 60;
|
|
31
33
|
function base64urlDecode(input) {
|
|
32
34
|
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
33
35
|
const padLength = (4 - base64.length % 4) % 4;
|
|
@@ -140,10 +142,19 @@ async function verifyWeb3Signed(params) {
|
|
|
140
142
|
if (payload.iat > now + CLOCK_SKEW_SECONDS) {
|
|
141
143
|
throw new import_errors.ExpiredTokenError({ reason: "Token issued in the future" });
|
|
142
144
|
}
|
|
145
|
+
const lifetime = payload.exp - payload.iat;
|
|
146
|
+
if (lifetime <= 0 || lifetime > MAX_WEB3SIGNED_LIFETIME_SECONDS) {
|
|
147
|
+
throw new import_errors.ExpiredTokenError({
|
|
148
|
+
reason: "Token lifetime exceeds the maximum",
|
|
149
|
+
maxLifetimeSeconds: MAX_WEB3SIGNED_LIFETIME_SECONDS,
|
|
150
|
+
lifetimeSeconds: lifetime
|
|
151
|
+
});
|
|
152
|
+
}
|
|
143
153
|
return { signer, payload };
|
|
144
154
|
}
|
|
145
155
|
// Annotate the CommonJS export names for ESM import in node:
|
|
146
156
|
0 && (module.exports = {
|
|
157
|
+
MAX_WEB3SIGNED_LIFETIME_SECONDS,
|
|
147
158
|
parseWeb3SignedHeader,
|
|
148
159
|
verifyWeb3Signed
|
|
149
160
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/auth/web3-signed.ts"],"sourcesContent":["/**\n * Web3Signed Authorization header parsing and verification.\n *\n * @remarks\n * Header format: `\"Web3Signed {base64url(payload)}.{signature}\"`.\n * Payload is JSON with fields `aud`, `method`, `uri`, `bodyHash`, `iat`, `exp`,\n * and optional `grantId`. The signature is EIP-191 over the base64url-encoded\n * payload string.\n *\n * Ported from `personal-server-ts` (`packages/core/src/auth/web3-signed.ts`).\n * Adjusted to be isomorphic (no `Buffer`) and to use SDK-local error types.\n *\n * @category Auth\n */\n\nimport { recoverMessageAddress } from \"viem\";\nimport { fromBase64 } from \"../utils/encoding\";\nimport {\n MissingAuthError,\n InvalidSignatureError,\n ExpiredTokenError,\n} from \"./errors\";\nimport { computeBodyHash } from \"./web3-signed-builder\";\n\nexport interface Web3SignedPayload {\n aud: string;\n method: string;\n uri: string;\n bodyHash: string;\n iat: number;\n exp: number;\n grantId?: string;\n}\n\nexport interface VerifiedAuth {\n signer: `0x${string}`;\n payload: Web3SignedPayload;\n}\n\nconst WEB3_SIGNED_PREFIX = \"Web3Signed \";\nconst CLOCK_SKEW_SECONDS = 60;\n\n/** Decode a base64url string (no padding) to UTF-8. */\nfunction base64urlDecode(input: string): string {\n let base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const padLength = (4 - (base64.length % 4)) % 4;\n base64 += \"=\".repeat(padLength);\n return new TextDecoder().decode(fromBase64(base64));\n}\n\nfunction isFiniteNumber(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value);\n}\n\nfunction parsePayload(value: unknown): Web3SignedPayload {\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) {\n throw new InvalidSignatureError({ reason: \"Invalid payload shape\" });\n }\n\n const payload = value as Record<string, unknown>;\n if (\n typeof payload[\"aud\"] !== \"string\" ||\n typeof payload[\"method\"] !== \"string\" ||\n typeof payload[\"uri\"] !== \"string\" ||\n typeof payload[\"bodyHash\"] !== \"string\" ||\n !isFiniteNumber(payload[\"iat\"]) ||\n !isFiniteNumber(payload[\"exp\"])\n ) {\n throw new InvalidSignatureError({ reason: \"Invalid payload claims\" });\n }\n\n if (\n payload[\"grantId\"] !== undefined &&\n typeof payload[\"grantId\"] !== \"string\"\n ) {\n throw new InvalidSignatureError({ reason: \"Invalid grantId claim\" });\n }\n\n return {\n aud: payload[\"aud\"],\n method: payload[\"method\"],\n uri: payload[\"uri\"],\n bodyHash: payload[\"bodyHash\"],\n iat: payload[\"iat\"],\n exp: payload[\"exp\"],\n grantId: payload[\"grantId\"],\n };\n}\n\n/**\n * Parse a `\"Web3Signed <base64url>.<signature>\"` header value.\n *\n * @throws {MissingAuthError} If the header is missing or empty.\n * @throws {InvalidSignatureError} If the format is invalid.\n */\nexport function parseWeb3SignedHeader(headerValue: string | undefined): {\n payloadBase64: string;\n payload: Web3SignedPayload;\n signature: `0x${string}`;\n} {\n if (!headerValue) {\n throw new MissingAuthError();\n }\n\n if (!headerValue.startsWith(WEB3_SIGNED_PREFIX)) {\n throw new InvalidSignatureError({ reason: \"Missing Web3Signed prefix\" });\n }\n\n const value = headerValue.slice(WEB3_SIGNED_PREFIX.length);\n const dotIndex = value.indexOf(\".\");\n if (dotIndex === -1 || dotIndex === 0 || dotIndex === value.length - 1) {\n throw new InvalidSignatureError({ reason: \"Invalid header format\" });\n }\n\n const payloadBase64 = value.slice(0, dotIndex);\n const signatureStr = value.slice(dotIndex + 1);\n\n if (!signatureStr.startsWith(\"0x\")) {\n throw new InvalidSignatureError({ reason: \"Invalid signature format\" });\n }\n\n let payload: Web3SignedPayload;\n try {\n const decoded = base64urlDecode(payloadBase64);\n payload = parsePayload(JSON.parse(decoded));\n } catch (err) {\n if (err instanceof InvalidSignatureError) throw err;\n throw new InvalidSignatureError({ reason: \"Invalid payload encoding\" });\n }\n\n return {\n payloadBase64,\n payload,\n signature: signatureStr as `0x${string}`,\n };\n}\n\n/**\n * Full verification: parse header, recover signer via EIP-191, check claims.\n *\n * @remarks\n * Steps:\n * 1. Parse header to base64url + signature.\n * 2. Recover signer via {@link recoverMessageAddress} (EIP-191) over the base64url payload string.\n * 3. Check `aud === expectedOrigin`, `method === expectedMethod`, `uri === expectedPath`.\n * 4. Optionally check `bodyHash` against `bodyBytes`.\n * 5. Check `iat`/`exp` within a 60s clock skew.\n *\n * @returns The recovered signer address and parsed payload.\n */\nexport async function verifyWeb3Signed(params: {\n headerValue: string | undefined;\n expectedOrigin: string;\n expectedMethod: string;\n expectedPath: string;\n bodyBytes?: Uint8Array;\n now?: number;\n}): Promise<VerifiedAuth> {\n const { payloadBase64, payload, signature } = parseWeb3SignedHeader(\n params.headerValue,\n );\n\n let signer: `0x${string}`;\n try {\n signer = await recoverMessageAddress({\n message: payloadBase64,\n signature,\n });\n } catch {\n throw new InvalidSignatureError({ reason: \"Signature recovery failed\" });\n }\n\n if (payload.aud !== params.expectedOrigin) {\n throw new InvalidSignatureError({\n reason: \"Audience mismatch\",\n expected: params.expectedOrigin,\n actual: payload.aud,\n });\n }\n\n if (payload.method !== params.expectedMethod) {\n throw new InvalidSignatureError({\n reason: \"Method mismatch\",\n expected: params.expectedMethod,\n actual: payload.method,\n });\n }\n\n if (payload.uri !== params.expectedPath) {\n throw new InvalidSignatureError({\n reason: \"URI mismatch\",\n expected: params.expectedPath,\n actual: payload.uri,\n });\n }\n\n if (params.bodyBytes !== undefined) {\n const expectedBodyHash = computeBodyHash(params.bodyBytes);\n if (payload.bodyHash !== expectedBodyHash) {\n throw new InvalidSignatureError({\n reason: \"Body hash mismatch\",\n expected: expectedBodyHash,\n actual: payload.bodyHash,\n });\n }\n }\n\n const now = params.now ?? Math.floor(Date.now() / 1000);\n\n if (payload.exp < now - CLOCK_SKEW_SECONDS) {\n throw new ExpiredTokenError({ reason: \"Token expired\" });\n }\n\n if (payload.iat > now + CLOCK_SKEW_SECONDS) {\n throw new ExpiredTokenError({ reason: \"Token issued in the future\" });\n }\n\n return { signer, payload };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/auth/web3-signed.ts"],"sourcesContent":["/**\n * Web3Signed Authorization header parsing and verification.\n *\n * @remarks\n * Header format: `\"Web3Signed {base64url(payload)}.{signature}\"`.\n * Payload is JSON with fields `aud`, `method`, `uri`, `bodyHash`, `iat`, `exp`,\n * and optional `grantId`. The signature is EIP-191 over the base64url-encoded\n * payload string.\n *\n * Ported from `personal-server-ts` (`packages/core/src/auth/web3-signed.ts`).\n * Adjusted to be isomorphic (no `Buffer`) and to use SDK-local error types.\n *\n * @category Auth\n */\n\nimport { recoverMessageAddress } from \"viem\";\nimport { fromBase64 } from \"../utils/encoding\";\nimport {\n MissingAuthError,\n InvalidSignatureError,\n ExpiredTokenError,\n} from \"./errors\";\nimport { computeBodyHash } from \"./web3-signed-builder\";\n\nexport interface Web3SignedPayload {\n aud: string;\n method: string;\n uri: string;\n bodyHash: string;\n iat: number;\n exp: number;\n grantId?: string;\n}\n\nexport interface VerifiedAuth {\n signer: `0x${string}`;\n payload: Web3SignedPayload;\n}\n\nconst WEB3_SIGNED_PREFIX = \"Web3Signed \";\nconst CLOCK_SKEW_SECONDS = 60;\n\n/**\n * Longest `exp - iat` window a Web3Signed header may carry (seconds). Without\n * this bound the signer chooses how long a captured request stays replayable.\n *\n * @category Auth\n */\nexport const MAX_WEB3SIGNED_LIFETIME_SECONDS = 15 * 60;\n\n/** Decode a base64url string (no padding) to UTF-8. */\nfunction base64urlDecode(input: string): string {\n let base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const padLength = (4 - (base64.length % 4)) % 4;\n base64 += \"=\".repeat(padLength);\n return new TextDecoder().decode(fromBase64(base64));\n}\n\nfunction isFiniteNumber(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value);\n}\n\nfunction parsePayload(value: unknown): Web3SignedPayload {\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) {\n throw new InvalidSignatureError({ reason: \"Invalid payload shape\" });\n }\n\n const payload = value as Record<string, unknown>;\n if (\n typeof payload[\"aud\"] !== \"string\" ||\n typeof payload[\"method\"] !== \"string\" ||\n typeof payload[\"uri\"] !== \"string\" ||\n typeof payload[\"bodyHash\"] !== \"string\" ||\n !isFiniteNumber(payload[\"iat\"]) ||\n !isFiniteNumber(payload[\"exp\"])\n ) {\n throw new InvalidSignatureError({ reason: \"Invalid payload claims\" });\n }\n\n if (\n payload[\"grantId\"] !== undefined &&\n typeof payload[\"grantId\"] !== \"string\"\n ) {\n throw new InvalidSignatureError({ reason: \"Invalid grantId claim\" });\n }\n\n return {\n aud: payload[\"aud\"],\n method: payload[\"method\"],\n uri: payload[\"uri\"],\n bodyHash: payload[\"bodyHash\"],\n iat: payload[\"iat\"],\n exp: payload[\"exp\"],\n grantId: payload[\"grantId\"],\n };\n}\n\n/**\n * Parse a `\"Web3Signed <base64url>.<signature>\"` header value.\n *\n * @throws {MissingAuthError} If the header is missing or empty.\n * @throws {InvalidSignatureError} If the format is invalid.\n */\nexport function parseWeb3SignedHeader(headerValue: string | undefined): {\n payloadBase64: string;\n payload: Web3SignedPayload;\n signature: `0x${string}`;\n} {\n if (!headerValue) {\n throw new MissingAuthError();\n }\n\n if (!headerValue.startsWith(WEB3_SIGNED_PREFIX)) {\n throw new InvalidSignatureError({ reason: \"Missing Web3Signed prefix\" });\n }\n\n const value = headerValue.slice(WEB3_SIGNED_PREFIX.length);\n const dotIndex = value.indexOf(\".\");\n if (dotIndex === -1 || dotIndex === 0 || dotIndex === value.length - 1) {\n throw new InvalidSignatureError({ reason: \"Invalid header format\" });\n }\n\n const payloadBase64 = value.slice(0, dotIndex);\n const signatureStr = value.slice(dotIndex + 1);\n\n if (!signatureStr.startsWith(\"0x\")) {\n throw new InvalidSignatureError({ reason: \"Invalid signature format\" });\n }\n\n let payload: Web3SignedPayload;\n try {\n const decoded = base64urlDecode(payloadBase64);\n payload = parsePayload(JSON.parse(decoded));\n } catch (err) {\n if (err instanceof InvalidSignatureError) throw err;\n throw new InvalidSignatureError({ reason: \"Invalid payload encoding\" });\n }\n\n return {\n payloadBase64,\n payload,\n signature: signatureStr as `0x${string}`,\n };\n}\n\n/**\n * Full verification: parse header, recover signer via EIP-191, check claims.\n *\n * @remarks\n * Steps:\n * 1. Parse header to base64url + signature.\n * 2. Recover signer via {@link recoverMessageAddress} (EIP-191) over the base64url payload string.\n * 3. Check `aud === expectedOrigin`, `method === expectedMethod`, `uri === expectedPath`.\n * 4. Optionally check `bodyHash` against `bodyBytes`.\n * 5. Check `iat`/`exp` within a 60s clock skew.\n *\n * @returns The recovered signer address and parsed payload.\n */\nexport async function verifyWeb3Signed(params: {\n headerValue: string | undefined;\n expectedOrigin: string;\n expectedMethod: string;\n expectedPath: string;\n bodyBytes?: Uint8Array;\n now?: number;\n}): Promise<VerifiedAuth> {\n const { payloadBase64, payload, signature } = parseWeb3SignedHeader(\n params.headerValue,\n );\n\n let signer: `0x${string}`;\n try {\n signer = await recoverMessageAddress({\n message: payloadBase64,\n signature,\n });\n } catch {\n throw new InvalidSignatureError({ reason: \"Signature recovery failed\" });\n }\n\n if (payload.aud !== params.expectedOrigin) {\n throw new InvalidSignatureError({\n reason: \"Audience mismatch\",\n expected: params.expectedOrigin,\n actual: payload.aud,\n });\n }\n\n if (payload.method !== params.expectedMethod) {\n throw new InvalidSignatureError({\n reason: \"Method mismatch\",\n expected: params.expectedMethod,\n actual: payload.method,\n });\n }\n\n if (payload.uri !== params.expectedPath) {\n throw new InvalidSignatureError({\n reason: \"URI mismatch\",\n expected: params.expectedPath,\n actual: payload.uri,\n });\n }\n\n if (params.bodyBytes !== undefined) {\n const expectedBodyHash = computeBodyHash(params.bodyBytes);\n if (payload.bodyHash !== expectedBodyHash) {\n throw new InvalidSignatureError({\n reason: \"Body hash mismatch\",\n expected: expectedBodyHash,\n actual: payload.bodyHash,\n });\n }\n }\n\n const now = params.now ?? Math.floor(Date.now() / 1000);\n\n if (payload.exp < now - CLOCK_SKEW_SECONDS) {\n throw new ExpiredTokenError({ reason: \"Token expired\" });\n }\n\n if (payload.iat > now + CLOCK_SKEW_SECONDS) {\n throw new ExpiredTokenError({ reason: \"Token issued in the future\" });\n }\n\n // Bound the window the signer chose, not just where `now` falls in it:\n // otherwise a captured request stays replayable for as long as the signer\n // liked.\n const lifetime = payload.exp - payload.iat;\n if (lifetime <= 0 || lifetime > MAX_WEB3SIGNED_LIFETIME_SECONDS) {\n throw new ExpiredTokenError({\n reason: \"Token lifetime exceeds the maximum\",\n maxLifetimeSeconds: MAX_WEB3SIGNED_LIFETIME_SECONDS,\n lifetimeSeconds: lifetime,\n });\n }\n\n return { signer, payload };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -25,6 +25,13 @@ export interface VerifiedAuth {
|
|
|
25
25
|
signer: `0x${string}`;
|
|
26
26
|
payload: Web3SignedPayload;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Longest `exp - iat` window a Web3Signed header may carry (seconds). Without
|
|
30
|
+
* this bound the signer chooses how long a captured request stays replayable.
|
|
31
|
+
*
|
|
32
|
+
* @category Auth
|
|
33
|
+
*/
|
|
34
|
+
export declare const MAX_WEB3SIGNED_LIFETIME_SECONDS: number;
|
|
28
35
|
/**
|
|
29
36
|
* Parse a `"Web3Signed <base64url>.<signature>"` header value.
|
|
30
37
|
*
|
package/dist/auth/web3-signed.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { computeBodyHash } from "./web3-signed-builder.js";
|
|
9
9
|
const WEB3_SIGNED_PREFIX = "Web3Signed ";
|
|
10
10
|
const CLOCK_SKEW_SECONDS = 60;
|
|
11
|
+
const MAX_WEB3SIGNED_LIFETIME_SECONDS = 15 * 60;
|
|
11
12
|
function base64urlDecode(input) {
|
|
12
13
|
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
13
14
|
const padLength = (4 - base64.length % 4) % 4;
|
|
@@ -120,9 +121,18 @@ async function verifyWeb3Signed(params) {
|
|
|
120
121
|
if (payload.iat > now + CLOCK_SKEW_SECONDS) {
|
|
121
122
|
throw new ExpiredTokenError({ reason: "Token issued in the future" });
|
|
122
123
|
}
|
|
124
|
+
const lifetime = payload.exp - payload.iat;
|
|
125
|
+
if (lifetime <= 0 || lifetime > MAX_WEB3SIGNED_LIFETIME_SECONDS) {
|
|
126
|
+
throw new ExpiredTokenError({
|
|
127
|
+
reason: "Token lifetime exceeds the maximum",
|
|
128
|
+
maxLifetimeSeconds: MAX_WEB3SIGNED_LIFETIME_SECONDS,
|
|
129
|
+
lifetimeSeconds: lifetime
|
|
130
|
+
});
|
|
131
|
+
}
|
|
123
132
|
return { signer, payload };
|
|
124
133
|
}
|
|
125
134
|
export {
|
|
135
|
+
MAX_WEB3SIGNED_LIFETIME_SECONDS,
|
|
126
136
|
parseWeb3SignedHeader,
|
|
127
137
|
verifyWeb3Signed
|
|
128
138
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/auth/web3-signed.ts"],"sourcesContent":["/**\n * Web3Signed Authorization header parsing and verification.\n *\n * @remarks\n * Header format: `\"Web3Signed {base64url(payload)}.{signature}\"`.\n * Payload is JSON with fields `aud`, `method`, `uri`, `bodyHash`, `iat`, `exp`,\n * and optional `grantId`. The signature is EIP-191 over the base64url-encoded\n * payload string.\n *\n * Ported from `personal-server-ts` (`packages/core/src/auth/web3-signed.ts`).\n * Adjusted to be isomorphic (no `Buffer`) and to use SDK-local error types.\n *\n * @category Auth\n */\n\nimport { recoverMessageAddress } from \"viem\";\nimport { fromBase64 } from \"../utils/encoding\";\nimport {\n MissingAuthError,\n InvalidSignatureError,\n ExpiredTokenError,\n} from \"./errors\";\nimport { computeBodyHash } from \"./web3-signed-builder\";\n\nexport interface Web3SignedPayload {\n aud: string;\n method: string;\n uri: string;\n bodyHash: string;\n iat: number;\n exp: number;\n grantId?: string;\n}\n\nexport interface VerifiedAuth {\n signer: `0x${string}`;\n payload: Web3SignedPayload;\n}\n\nconst WEB3_SIGNED_PREFIX = \"Web3Signed \";\nconst CLOCK_SKEW_SECONDS = 60;\n\n/** Decode a base64url string (no padding) to UTF-8. */\nfunction base64urlDecode(input: string): string {\n let base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const padLength = (4 - (base64.length % 4)) % 4;\n base64 += \"=\".repeat(padLength);\n return new TextDecoder().decode(fromBase64(base64));\n}\n\nfunction isFiniteNumber(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value);\n}\n\nfunction parsePayload(value: unknown): Web3SignedPayload {\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) {\n throw new InvalidSignatureError({ reason: \"Invalid payload shape\" });\n }\n\n const payload = value as Record<string, unknown>;\n if (\n typeof payload[\"aud\"] !== \"string\" ||\n typeof payload[\"method\"] !== \"string\" ||\n typeof payload[\"uri\"] !== \"string\" ||\n typeof payload[\"bodyHash\"] !== \"string\" ||\n !isFiniteNumber(payload[\"iat\"]) ||\n !isFiniteNumber(payload[\"exp\"])\n ) {\n throw new InvalidSignatureError({ reason: \"Invalid payload claims\" });\n }\n\n if (\n payload[\"grantId\"] !== undefined &&\n typeof payload[\"grantId\"] !== \"string\"\n ) {\n throw new InvalidSignatureError({ reason: \"Invalid grantId claim\" });\n }\n\n return {\n aud: payload[\"aud\"],\n method: payload[\"method\"],\n uri: payload[\"uri\"],\n bodyHash: payload[\"bodyHash\"],\n iat: payload[\"iat\"],\n exp: payload[\"exp\"],\n grantId: payload[\"grantId\"],\n };\n}\n\n/**\n * Parse a `\"Web3Signed <base64url>.<signature>\"` header value.\n *\n * @throws {MissingAuthError} If the header is missing or empty.\n * @throws {InvalidSignatureError} If the format is invalid.\n */\nexport function parseWeb3SignedHeader(headerValue: string | undefined): {\n payloadBase64: string;\n payload: Web3SignedPayload;\n signature: `0x${string}`;\n} {\n if (!headerValue) {\n throw new MissingAuthError();\n }\n\n if (!headerValue.startsWith(WEB3_SIGNED_PREFIX)) {\n throw new InvalidSignatureError({ reason: \"Missing Web3Signed prefix\" });\n }\n\n const value = headerValue.slice(WEB3_SIGNED_PREFIX.length);\n const dotIndex = value.indexOf(\".\");\n if (dotIndex === -1 || dotIndex === 0 || dotIndex === value.length - 1) {\n throw new InvalidSignatureError({ reason: \"Invalid header format\" });\n }\n\n const payloadBase64 = value.slice(0, dotIndex);\n const signatureStr = value.slice(dotIndex + 1);\n\n if (!signatureStr.startsWith(\"0x\")) {\n throw new InvalidSignatureError({ reason: \"Invalid signature format\" });\n }\n\n let payload: Web3SignedPayload;\n try {\n const decoded = base64urlDecode(payloadBase64);\n payload = parsePayload(JSON.parse(decoded));\n } catch (err) {\n if (err instanceof InvalidSignatureError) throw err;\n throw new InvalidSignatureError({ reason: \"Invalid payload encoding\" });\n }\n\n return {\n payloadBase64,\n payload,\n signature: signatureStr as `0x${string}`,\n };\n}\n\n/**\n * Full verification: parse header, recover signer via EIP-191, check claims.\n *\n * @remarks\n * Steps:\n * 1. Parse header to base64url + signature.\n * 2. Recover signer via {@link recoverMessageAddress} (EIP-191) over the base64url payload string.\n * 3. Check `aud === expectedOrigin`, `method === expectedMethod`, `uri === expectedPath`.\n * 4. Optionally check `bodyHash` against `bodyBytes`.\n * 5. Check `iat`/`exp` within a 60s clock skew.\n *\n * @returns The recovered signer address and parsed payload.\n */\nexport async function verifyWeb3Signed(params: {\n headerValue: string | undefined;\n expectedOrigin: string;\n expectedMethod: string;\n expectedPath: string;\n bodyBytes?: Uint8Array;\n now?: number;\n}): Promise<VerifiedAuth> {\n const { payloadBase64, payload, signature } = parseWeb3SignedHeader(\n params.headerValue,\n );\n\n let signer: `0x${string}`;\n try {\n signer = await recoverMessageAddress({\n message: payloadBase64,\n signature,\n });\n } catch {\n throw new InvalidSignatureError({ reason: \"Signature recovery failed\" });\n }\n\n if (payload.aud !== params.expectedOrigin) {\n throw new InvalidSignatureError({\n reason: \"Audience mismatch\",\n expected: params.expectedOrigin,\n actual: payload.aud,\n });\n }\n\n if (payload.method !== params.expectedMethod) {\n throw new InvalidSignatureError({\n reason: \"Method mismatch\",\n expected: params.expectedMethod,\n actual: payload.method,\n });\n }\n\n if (payload.uri !== params.expectedPath) {\n throw new InvalidSignatureError({\n reason: \"URI mismatch\",\n expected: params.expectedPath,\n actual: payload.uri,\n });\n }\n\n if (params.bodyBytes !== undefined) {\n const expectedBodyHash = computeBodyHash(params.bodyBytes);\n if (payload.bodyHash !== expectedBodyHash) {\n throw new InvalidSignatureError({\n reason: \"Body hash mismatch\",\n expected: expectedBodyHash,\n actual: payload.bodyHash,\n });\n }\n }\n\n const now = params.now ?? Math.floor(Date.now() / 1000);\n\n if (payload.exp < now - CLOCK_SKEW_SECONDS) {\n throw new ExpiredTokenError({ reason: \"Token expired\" });\n }\n\n if (payload.iat > now + CLOCK_SKEW_SECONDS) {\n throw new ExpiredTokenError({ reason: \"Token issued in the future\" });\n }\n\n return { signer, payload };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/auth/web3-signed.ts"],"sourcesContent":["/**\n * Web3Signed Authorization header parsing and verification.\n *\n * @remarks\n * Header format: `\"Web3Signed {base64url(payload)}.{signature}\"`.\n * Payload is JSON with fields `aud`, `method`, `uri`, `bodyHash`, `iat`, `exp`,\n * and optional `grantId`. The signature is EIP-191 over the base64url-encoded\n * payload string.\n *\n * Ported from `personal-server-ts` (`packages/core/src/auth/web3-signed.ts`).\n * Adjusted to be isomorphic (no `Buffer`) and to use SDK-local error types.\n *\n * @category Auth\n */\n\nimport { recoverMessageAddress } from \"viem\";\nimport { fromBase64 } from \"../utils/encoding\";\nimport {\n MissingAuthError,\n InvalidSignatureError,\n ExpiredTokenError,\n} from \"./errors\";\nimport { computeBodyHash } from \"./web3-signed-builder\";\n\nexport interface Web3SignedPayload {\n aud: string;\n method: string;\n uri: string;\n bodyHash: string;\n iat: number;\n exp: number;\n grantId?: string;\n}\n\nexport interface VerifiedAuth {\n signer: `0x${string}`;\n payload: Web3SignedPayload;\n}\n\nconst WEB3_SIGNED_PREFIX = \"Web3Signed \";\nconst CLOCK_SKEW_SECONDS = 60;\n\n/**\n * Longest `exp - iat` window a Web3Signed header may carry (seconds). Without\n * this bound the signer chooses how long a captured request stays replayable.\n *\n * @category Auth\n */\nexport const MAX_WEB3SIGNED_LIFETIME_SECONDS = 15 * 60;\n\n/** Decode a base64url string (no padding) to UTF-8. */\nfunction base64urlDecode(input: string): string {\n let base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const padLength = (4 - (base64.length % 4)) % 4;\n base64 += \"=\".repeat(padLength);\n return new TextDecoder().decode(fromBase64(base64));\n}\n\nfunction isFiniteNumber(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value);\n}\n\nfunction parsePayload(value: unknown): Web3SignedPayload {\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) {\n throw new InvalidSignatureError({ reason: \"Invalid payload shape\" });\n }\n\n const payload = value as Record<string, unknown>;\n if (\n typeof payload[\"aud\"] !== \"string\" ||\n typeof payload[\"method\"] !== \"string\" ||\n typeof payload[\"uri\"] !== \"string\" ||\n typeof payload[\"bodyHash\"] !== \"string\" ||\n !isFiniteNumber(payload[\"iat\"]) ||\n !isFiniteNumber(payload[\"exp\"])\n ) {\n throw new InvalidSignatureError({ reason: \"Invalid payload claims\" });\n }\n\n if (\n payload[\"grantId\"] !== undefined &&\n typeof payload[\"grantId\"] !== \"string\"\n ) {\n throw new InvalidSignatureError({ reason: \"Invalid grantId claim\" });\n }\n\n return {\n aud: payload[\"aud\"],\n method: payload[\"method\"],\n uri: payload[\"uri\"],\n bodyHash: payload[\"bodyHash\"],\n iat: payload[\"iat\"],\n exp: payload[\"exp\"],\n grantId: payload[\"grantId\"],\n };\n}\n\n/**\n * Parse a `\"Web3Signed <base64url>.<signature>\"` header value.\n *\n * @throws {MissingAuthError} If the header is missing or empty.\n * @throws {InvalidSignatureError} If the format is invalid.\n */\nexport function parseWeb3SignedHeader(headerValue: string | undefined): {\n payloadBase64: string;\n payload: Web3SignedPayload;\n signature: `0x${string}`;\n} {\n if (!headerValue) {\n throw new MissingAuthError();\n }\n\n if (!headerValue.startsWith(WEB3_SIGNED_PREFIX)) {\n throw new InvalidSignatureError({ reason: \"Missing Web3Signed prefix\" });\n }\n\n const value = headerValue.slice(WEB3_SIGNED_PREFIX.length);\n const dotIndex = value.indexOf(\".\");\n if (dotIndex === -1 || dotIndex === 0 || dotIndex === value.length - 1) {\n throw new InvalidSignatureError({ reason: \"Invalid header format\" });\n }\n\n const payloadBase64 = value.slice(0, dotIndex);\n const signatureStr = value.slice(dotIndex + 1);\n\n if (!signatureStr.startsWith(\"0x\")) {\n throw new InvalidSignatureError({ reason: \"Invalid signature format\" });\n }\n\n let payload: Web3SignedPayload;\n try {\n const decoded = base64urlDecode(payloadBase64);\n payload = parsePayload(JSON.parse(decoded));\n } catch (err) {\n if (err instanceof InvalidSignatureError) throw err;\n throw new InvalidSignatureError({ reason: \"Invalid payload encoding\" });\n }\n\n return {\n payloadBase64,\n payload,\n signature: signatureStr as `0x${string}`,\n };\n}\n\n/**\n * Full verification: parse header, recover signer via EIP-191, check claims.\n *\n * @remarks\n * Steps:\n * 1. Parse header to base64url + signature.\n * 2. Recover signer via {@link recoverMessageAddress} (EIP-191) over the base64url payload string.\n * 3. Check `aud === expectedOrigin`, `method === expectedMethod`, `uri === expectedPath`.\n * 4. Optionally check `bodyHash` against `bodyBytes`.\n * 5. Check `iat`/`exp` within a 60s clock skew.\n *\n * @returns The recovered signer address and parsed payload.\n */\nexport async function verifyWeb3Signed(params: {\n headerValue: string | undefined;\n expectedOrigin: string;\n expectedMethod: string;\n expectedPath: string;\n bodyBytes?: Uint8Array;\n now?: number;\n}): Promise<VerifiedAuth> {\n const { payloadBase64, payload, signature } = parseWeb3SignedHeader(\n params.headerValue,\n );\n\n let signer: `0x${string}`;\n try {\n signer = await recoverMessageAddress({\n message: payloadBase64,\n signature,\n });\n } catch {\n throw new InvalidSignatureError({ reason: \"Signature recovery failed\" });\n }\n\n if (payload.aud !== params.expectedOrigin) {\n throw new InvalidSignatureError({\n reason: \"Audience mismatch\",\n expected: params.expectedOrigin,\n actual: payload.aud,\n });\n }\n\n if (payload.method !== params.expectedMethod) {\n throw new InvalidSignatureError({\n reason: \"Method mismatch\",\n expected: params.expectedMethod,\n actual: payload.method,\n });\n }\n\n if (payload.uri !== params.expectedPath) {\n throw new InvalidSignatureError({\n reason: \"URI mismatch\",\n expected: params.expectedPath,\n actual: payload.uri,\n });\n }\n\n if (params.bodyBytes !== undefined) {\n const expectedBodyHash = computeBodyHash(params.bodyBytes);\n if (payload.bodyHash !== expectedBodyHash) {\n throw new InvalidSignatureError({\n reason: \"Body hash mismatch\",\n expected: expectedBodyHash,\n actual: payload.bodyHash,\n });\n }\n }\n\n const now = params.now ?? Math.floor(Date.now() / 1000);\n\n if (payload.exp < now - CLOCK_SKEW_SECONDS) {\n throw new ExpiredTokenError({ reason: \"Token expired\" });\n }\n\n if (payload.iat > now + CLOCK_SKEW_SECONDS) {\n throw new ExpiredTokenError({ reason: \"Token issued in the future\" });\n }\n\n // Bound the window the signer chose, not just where `now` falls in it:\n // otherwise a captured request stays replayable for as long as the signer\n // liked.\n const lifetime = payload.exp - payload.iat;\n if (lifetime <= 0 || lifetime > MAX_WEB3SIGNED_LIFETIME_SECONDS) {\n throw new ExpiredTokenError({\n reason: \"Token lifetime exceeds the maximum\",\n maxLifetimeSeconds: MAX_WEB3SIGNED_LIFETIME_SECONDS,\n lifetimeSeconds: lifetime,\n });\n }\n\n return { signer, payload };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var account_origin_exports = {};
|
|
20
|
+
__export(account_origin_exports, {
|
|
21
|
+
parseOriginAllowlist: () => parseOriginAllowlist,
|
|
22
|
+
resolveVanaAccountOrigin: () => resolveVanaAccountOrigin
|
|
23
|
+
});
|
|
24
|
+
module.exports = __toCommonJS(account_origin_exports);
|
|
25
|
+
var import_protocol = require("./protocol");
|
|
26
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
27
|
+
const HOSTNAME = /^(?:\[[0-9a-f:.]+\]|[a-z0-9-]+(?:\.[a-z0-9-]+)*)$/;
|
|
28
|
+
function exactOrigin(value, protocol) {
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(value);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return url.protocol === protocol && url.origin === value && HOSTNAME.test(url.hostname) ? value : null;
|
|
36
|
+
}
|
|
37
|
+
function parseOriginAllowlist(csv) {
|
|
38
|
+
if (!csv) return [];
|
|
39
|
+
const origins = [];
|
|
40
|
+
for (const entry of csv.split(",")) {
|
|
41
|
+
const origin = exactOrigin(entry.trim(), "https:");
|
|
42
|
+
if (origin && !origins.includes(origin)) origins.push(origin);
|
|
43
|
+
}
|
|
44
|
+
return origins;
|
|
45
|
+
}
|
|
46
|
+
function resolveVanaAccountOrigin(input) {
|
|
47
|
+
if (input.env !== "production" && input.env !== "dev") {
|
|
48
|
+
throw new TypeError('env must be "production" or "dev"');
|
|
49
|
+
}
|
|
50
|
+
const fallback = import_protocol.VANA_ACCOUNT_ORIGINS[input.env];
|
|
51
|
+
const { override } = input;
|
|
52
|
+
if (input.allowOverride !== true || typeof override !== "string") {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
const https = exactOrigin(override, "https:");
|
|
56
|
+
if (https && (input.previewOrigins ?? []).includes(https)) {
|
|
57
|
+
return https;
|
|
58
|
+
}
|
|
59
|
+
if (input.allowLocal === true) {
|
|
60
|
+
const local = exactOrigin(override, "http:");
|
|
61
|
+
if (local && LOOPBACK_HOSTS.has(new URL(local).hostname)) {
|
|
62
|
+
return local;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
68
|
+
0 && (module.exports = {
|
|
69
|
+
parseOriginAllowlist,
|
|
70
|
+
resolveVanaAccountOrigin
|
|
71
|
+
});
|
|
72
|
+
//# sourceMappingURL=account-origin.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/connect/account-origin.ts"],"sourcesContent":["/**\n * Which Vana Account origin serves the connect sheet.\n *\n * @remarks\n * The sheet origin decides who the person signs in to and who receives the\n * partner's request capability, so it must never come from a pattern the\n * page URL can satisfy. {@link resolveVanaAccountOrigin} returns the\n * environment's Vana Account origin and accepts an override only when the\n * caller switched overrides on and the override is an exact origin from an\n * explicit allowlist. There is deliberately no pattern or wildcard option.\n *\n * Zero-dependency and browser-safe, like the protocol it builds on.\n *\n * @category Connect\n * @module connect/account-origin\n */\n\nimport { VANA_ACCOUNT_ORIGINS, type VanaConnectEnv } from \"./protocol\";\n\n/** Input for {@link resolveVanaAccountOrigin}. */\nexport interface ResolveVanaAccountOriginInput {\n /** The deployment environment the app talks to. Required: there is no default. */\n env: VanaConnectEnv;\n /**\n * A requested Account origin, typically read from a query parameter on a\n * preview deploy. Untrusted. Ignored unless {@link allowOverride} is true.\n */\n override?: string | null;\n /**\n * Exact `https` origins (`https://host[:port]`, no path, no trailing slash)\n * an override may name. Build it with {@link parseOriginAllowlist}. Entries\n * that are not exact https origins never match.\n */\n previewOrigins?: readonly string[];\n /**\n * Whether this build honours an override at all. Decide it from build-time\n * configuration (a non-production deploy), never from the request. Keep it\n * `false` in production.\n */\n allowOverride: boolean;\n /**\n * Also accept an exact `http` loopback origin (`localhost`, `127.0.0.1`,\n * `[::1]`, any port) without listing it, for a locally run Vana Account.\n * Only applies when {@link allowOverride} is true. Defaults to `false`.\n */\n allowLocal?: boolean;\n}\n\nconst LOOPBACK_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\"]);\n/** DNS labels (punycode included) or a bracketed IPv6 literal; no wildcards. */\nconst HOSTNAME = /^(?:\\[[0-9a-f:.]+\\]|[a-z0-9-]+(?:\\.[a-z0-9-]+)*)$/;\n\n/** `value` itself when it is a bare origin with `protocol`, else `null`. */\nfunction exactOrigin(\n value: string,\n protocol: \"https:\" | \"http:\",\n): string | null {\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return null;\n }\n // `url.origin === value` rejects paths, queries, fragments, credentials,\n // trailing slashes and any spelling the URL parser would rewrite.\n return url.protocol === protocol &&\n url.origin === value &&\n HOSTNAME.test(url.hostname)\n ? value\n : null;\n}\n\n/**\n * Parse a comma-separated allowlist of exact `https` origins.\n *\n * @remarks\n * Whitespace around entries is trimmed. An entry that is not an exact\n * `https://host[:port]` origin (a path, a trailing slash, `http:`, a\n * wildcard such as `https://*.vercel.app`) is dropped, so a typo narrows\n * the list instead of widening it.\n *\n * @example\n * ```typescript\n * parseOriginAllowlist(process.env.VANA_ACCOUNT_PREVIEW_ORIGINS);\n * // [\"https://account-git-my-branch.vercel.app\"]\n * ```\n *\n * @param csv - The raw list, for example an environment variable.\n * @returns The exact https origins, deduplicated, in order.\n */\nexport function parseOriginAllowlist(csv: string | null | undefined): string[] {\n if (!csv) return [];\n const origins: string[] = [];\n for (const entry of csv.split(\",\")) {\n const origin = exactOrigin(entry.trim(), \"https:\");\n if (origin && !origins.includes(origin)) origins.push(origin);\n }\n return origins;\n}\n\n/**\n * Resolve the Vana Account origin that serves the connect sheet.\n *\n * @remarks\n * Returns `VANA_ACCOUNT_ORIGINS[env]` unless `allowOverride` is true and\n * `override` is exactly one of `previewOrigins` (an `https` origin), or,\n * with `allowLocal`, exactly an `http` loopback origin. Anything else (a\n * path, a different scheme, a near-miss host, a lookalike) falls back to the\n * environment's origin. Matching is exact string equality: no patterns, no\n * suffixes, no normalization that could widen a match.\n *\n * @example\n * ```typescript\n * const accountOrigin = resolveVanaAccountOrigin({\n * env: \"dev\",\n * override: new URLSearchParams(location.search).get(\"vana_account\"),\n * previewOrigins: parseOriginAllowlist(\n * process.env.NEXT_PUBLIC_VANA_ACCOUNT_PREVIEW_ORIGINS,\n * ),\n * allowOverride: process.env.NEXT_PUBLIC_DEPLOY_ENV === \"preview\",\n * });\n * ```\n *\n * @param input - Environment, untrusted override, allowlist and switches.\n * @returns A bare Vana Account origin.\n * @throws TypeError when `env` is not `\"production\"` or `\"dev\"`.\n */\nexport function resolveVanaAccountOrigin(\n input: ResolveVanaAccountOriginInput,\n): string {\n if (input.env !== \"production\" && input.env !== \"dev\") {\n throw new TypeError('env must be \"production\" or \"dev\"');\n }\n const fallback: string = VANA_ACCOUNT_ORIGINS[input.env];\n const { override } = input;\n if (input.allowOverride !== true || typeof override !== \"string\") {\n return fallback;\n }\n const https = exactOrigin(override, \"https:\");\n if (https && (input.previewOrigins ?? []).includes(https)) {\n return https;\n }\n if (input.allowLocal === true) {\n const local = exactOrigin(override, \"http:\");\n if (local && LOOPBACK_HOSTS.has(new URL(local).hostname)) {\n return local;\n }\n }\n return fallback;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiBA,sBAA0D;AA+B1D,MAAM,iBAAiB,oBAAI,IAAI,CAAC,aAAa,aAAa,OAAO,CAAC;AAElE,MAAM,WAAW;AAGjB,SAAS,YACP,OACA,UACe;AACf,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,KAAK;AAAA,EACrB,QAAQ;AACN,WAAO;AAAA,EACT;AAGA,SAAO,IAAI,aAAa,YACtB,IAAI,WAAW,SACf,SAAS,KAAK,IAAI,QAAQ,IACxB,QACA;AACN;AAoBO,SAAS,qBAAqB,KAA0C;AAC7E,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,QAAM,UAAoB,CAAC;AAC3B,aAAW,SAAS,IAAI,MAAM,GAAG,GAAG;AAClC,UAAM,SAAS,YAAY,MAAM,KAAK,GAAG,QAAQ;AACjD,QAAI,UAAU,CAAC,QAAQ,SAAS,MAAM,EAAG,SAAQ,KAAK,MAAM;AAAA,EAC9D;AACA,SAAO;AACT;AA6BO,SAAS,yBACd,OACQ;AACR,MAAI,MAAM,QAAQ,gBAAgB,MAAM,QAAQ,OAAO;AACrD,UAAM,IAAI,UAAU,mCAAmC;AAAA,EACzD;AACA,QAAM,WAAmB,qCAAqB,MAAM,GAAG;AACvD,QAAM,EAAE,SAAS,IAAI;AACrB,MAAI,MAAM,kBAAkB,QAAQ,OAAO,aAAa,UAAU;AAChE,WAAO;AAAA,EACT;AACA,QAAM,QAAQ,YAAY,UAAU,QAAQ;AAC5C,MAAI,UAAU,MAAM,kBAAkB,CAAC,GAAG,SAAS,KAAK,GAAG;AACzD,WAAO;AAAA,EACT;AACA,MAAI,MAAM,eAAe,MAAM;AAC7B,UAAM,QAAQ,YAAY,UAAU,OAAO;AAC3C,QAAI,SAAS,eAAe,IAAI,IAAI,IAAI,KAAK,EAAE,QAAQ,GAAG;AACxD,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Vana Account origin serves the connect sheet.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* The sheet origin decides who the person signs in to and who receives the
|
|
6
|
+
* partner's request capability, so it must never come from a pattern the
|
|
7
|
+
* page URL can satisfy. {@link resolveVanaAccountOrigin} returns the
|
|
8
|
+
* environment's Vana Account origin and accepts an override only when the
|
|
9
|
+
* caller switched overrides on and the override is an exact origin from an
|
|
10
|
+
* explicit allowlist. There is deliberately no pattern or wildcard option.
|
|
11
|
+
*
|
|
12
|
+
* Zero-dependency and browser-safe, like the protocol it builds on.
|
|
13
|
+
*
|
|
14
|
+
* @category Connect
|
|
15
|
+
* @module connect/account-origin
|
|
16
|
+
*/
|
|
17
|
+
import { type VanaConnectEnv } from "./protocol.js";
|
|
18
|
+
/** Input for {@link resolveVanaAccountOrigin}. */
|
|
19
|
+
export interface ResolveVanaAccountOriginInput {
|
|
20
|
+
/** The deployment environment the app talks to. Required: there is no default. */
|
|
21
|
+
env: VanaConnectEnv;
|
|
22
|
+
/**
|
|
23
|
+
* A requested Account origin, typically read from a query parameter on a
|
|
24
|
+
* preview deploy. Untrusted. Ignored unless {@link allowOverride} is true.
|
|
25
|
+
*/
|
|
26
|
+
override?: string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Exact `https` origins (`https://host[:port]`, no path, no trailing slash)
|
|
29
|
+
* an override may name. Build it with {@link parseOriginAllowlist}. Entries
|
|
30
|
+
* that are not exact https origins never match.
|
|
31
|
+
*/
|
|
32
|
+
previewOrigins?: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* Whether this build honours an override at all. Decide it from build-time
|
|
35
|
+
* configuration (a non-production deploy), never from the request. Keep it
|
|
36
|
+
* `false` in production.
|
|
37
|
+
*/
|
|
38
|
+
allowOverride: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Also accept an exact `http` loopback origin (`localhost`, `127.0.0.1`,
|
|
41
|
+
* `[::1]`, any port) without listing it, for a locally run Vana Account.
|
|
42
|
+
* Only applies when {@link allowOverride} is true. Defaults to `false`.
|
|
43
|
+
*/
|
|
44
|
+
allowLocal?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse a comma-separated allowlist of exact `https` origins.
|
|
48
|
+
*
|
|
49
|
+
* @remarks
|
|
50
|
+
* Whitespace around entries is trimmed. An entry that is not an exact
|
|
51
|
+
* `https://host[:port]` origin (a path, a trailing slash, `http:`, a
|
|
52
|
+
* wildcard such as `https://*.vercel.app`) is dropped, so a typo narrows
|
|
53
|
+
* the list instead of widening it.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* parseOriginAllowlist(process.env.VANA_ACCOUNT_PREVIEW_ORIGINS);
|
|
58
|
+
* // ["https://account-git-my-branch.vercel.app"]
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* @param csv - The raw list, for example an environment variable.
|
|
62
|
+
* @returns The exact https origins, deduplicated, in order.
|
|
63
|
+
*/
|
|
64
|
+
export declare function parseOriginAllowlist(csv: string | null | undefined): string[];
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the Vana Account origin that serves the connect sheet.
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* Returns `VANA_ACCOUNT_ORIGINS[env]` unless `allowOverride` is true and
|
|
70
|
+
* `override` is exactly one of `previewOrigins` (an `https` origin), or,
|
|
71
|
+
* with `allowLocal`, exactly an `http` loopback origin. Anything else (a
|
|
72
|
+
* path, a different scheme, a near-miss host, a lookalike) falls back to the
|
|
73
|
+
* environment's origin. Matching is exact string equality: no patterns, no
|
|
74
|
+
* suffixes, no normalization that could widen a match.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* const accountOrigin = resolveVanaAccountOrigin({
|
|
79
|
+
* env: "dev",
|
|
80
|
+
* override: new URLSearchParams(location.search).get("vana_account"),
|
|
81
|
+
* previewOrigins: parseOriginAllowlist(
|
|
82
|
+
* process.env.NEXT_PUBLIC_VANA_ACCOUNT_PREVIEW_ORIGINS,
|
|
83
|
+
* ),
|
|
84
|
+
* allowOverride: process.env.NEXT_PUBLIC_DEPLOY_ENV === "preview",
|
|
85
|
+
* });
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* @param input - Environment, untrusted override, allowlist and switches.
|
|
89
|
+
* @returns A bare Vana Account origin.
|
|
90
|
+
* @throws TypeError when `env` is not `"production"` or `"dev"`.
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveVanaAccountOrigin(input: ResolveVanaAccountOriginInput): string;
|