@fastxyz/fastid-sdk 0.1.0
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/LICENSE +202 -0
- package/README.md +65 -0
- package/dist/address.d.ts +12 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +57 -0
- package/dist/address.js.map +1 -0
- package/dist/canonical-json.d.ts +12 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +95 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/claim-data.d.ts +9 -0
- package/dist/claim-data.d.ts.map +1 -0
- package/dist/claim-data.js +151 -0
- package/dist/claim-data.js.map +1 -0
- package/dist/claim-fee.d.ts +26 -0
- package/dist/claim-fee.d.ts.map +1 -0
- package/dist/claim-fee.js +144 -0
- package/dist/claim-fee.js.map +1 -0
- package/dist/claim-preflight.d.ts +7 -0
- package/dist/claim-preflight.d.ts.map +1 -0
- package/dist/claim-preflight.js +34 -0
- package/dist/claim-preflight.js.map +1 -0
- package/dist/claim-tx.d.ts +35 -0
- package/dist/claim-tx.d.ts.map +1 -0
- package/dist/claim-tx.js +140 -0
- package/dist/claim-tx.js.map +1 -0
- package/dist/claims.d.ts +44 -0
- package/dist/claims.d.ts.map +1 -0
- package/dist/claims.js +348 -0
- package/dist/claims.js.map +1 -0
- package/dist/client.d.ts +37 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +139 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +252 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +410 -0
- package/dist/errors.js.map +1 -0
- package/dist/fasttx.d.ts +10 -0
- package/dist/fasttx.d.ts.map +1 -0
- package/dist/fasttx.js +35 -0
- package/dist/fasttx.js.map +1 -0
- package/dist/http.d.ts +15 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +67 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/name.d.ts +15 -0
- package/dist/name.d.ts.map +1 -0
- package/dist/name.js +34 -0
- package/dist/name.js.map +1 -0
- package/dist/networks.d.ts +8 -0
- package/dist/networks.d.ts.map +1 -0
- package/dist/networks.js +32 -0
- package/dist/networks.js.map +1 -0
- package/dist/profile-message.d.ts +22 -0
- package/dist/profile-message.d.ts.map +1 -0
- package/dist/profile-message.js +53 -0
- package/dist/profile-message.js.map +1 -0
- package/dist/profile.d.ts +30 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +363 -0
- package/dist/profile.js.map +1 -0
- package/dist/proofs.d.ts +92 -0
- package/dist/proofs.d.ts.map +1 -0
- package/dist/proofs.js +770 -0
- package/dist/proofs.js.map +1 -0
- package/dist/reads.d.ts +76 -0
- package/dist/reads.d.ts.map +1 -0
- package/dist/reads.js +244 -0
- package/dist/reads.js.map +1 -0
- package/dist/report-wire.d.ts +35 -0
- package/dist/report-wire.d.ts.map +1 -0
- package/dist/report-wire.js +131 -0
- package/dist/report-wire.js.map +1 -0
- package/dist/report.d.ts +36 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +255 -0
- package/dist/report.js.map +1 -0
- package/dist/share.d.ts +9 -0
- package/dist/share.d.ts.map +1 -0
- package/dist/share.js +16 -0
- package/dist/share.js.map +1 -0
- package/dist/signer.d.ts +23 -0
- package/dist/signer.d.ts.map +1 -0
- package/dist/signer.js +73 -0
- package/dist/signer.js.map +1 -0
- package/dist/verify.d.ts +6 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +33 -0
- package/dist/verify.js.map +1 -0
- package/package.json +53 -0
package/dist/proofs.js
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Ported against the public proof flows in fastset-id-web/src/components/ProofFirstClaim.tsx,
|
|
3
|
+
* fastset-id-web/src/lib/social-intent.ts, and the matching app/api route forwarders.
|
|
4
|
+
*/
|
|
5
|
+
import { isCanonicalClaimValue } from "./claim-data.js";
|
|
6
|
+
import { InsufficientFundsError, InvalidProofInputError, LocalVerificationError, NonceConflictError, OAuthProofTimeoutError, OAuthProofUnavailableError, ProofProtocolError, ProofWaitCancelledError, PreSubmitError, RegistrationPendingError, WebsiteVerificationPendingError, XSettlementNotAuthorizedError, } from "./errors.js";
|
|
7
|
+
import { hexToBytes, verifyStrict } from "./verify.js";
|
|
8
|
+
function assertOAuthProvider(provider) {
|
|
9
|
+
if (provider !== "github" && provider !== "orcid") {
|
|
10
|
+
throw new InvalidProofInputError("oauth_provider", typeof provider === "string" ? provider : "");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function provesNoPaidAttempt(cause) {
|
|
14
|
+
return (cause instanceof PreSubmitError ||
|
|
15
|
+
cause instanceof NonceConflictError ||
|
|
16
|
+
cause instanceof InsufficientFundsError);
|
|
17
|
+
}
|
|
18
|
+
const SOCIAL_PROOF_DOMAIN = new TextEncoder().encode("fastid-social-proof-v2");
|
|
19
|
+
const SOCIAL_INTENT_DOMAIN = new TextEncoder().encode("fastid-social-intent-v1");
|
|
20
|
+
const SOCIAL_COMMIT_DOMAIN = new TextEncoder().encode("fastid-social-commit-v1");
|
|
21
|
+
const X_POST_LEAD_IN = "One identity across the Fast Network. Linking this account to my Fast ID:";
|
|
22
|
+
const INTENT_PROTOCOL = "intent-v1";
|
|
23
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
24
|
+
function u16be(value) {
|
|
25
|
+
return new Uint8Array([(value >>> 8) & 0xff, value & 0xff]);
|
|
26
|
+
}
|
|
27
|
+
function u64be(value) {
|
|
28
|
+
if (value < 0n || value > 0xffffffffffffffffn) {
|
|
29
|
+
throw new Error("expiry must fit u64");
|
|
30
|
+
}
|
|
31
|
+
const out = new Uint8Array(8);
|
|
32
|
+
let remaining = value;
|
|
33
|
+
for (let index = 7; index >= 0; index -= 1) {
|
|
34
|
+
out[index] = Number(remaining & 0xffn);
|
|
35
|
+
remaining >>= 8n;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function concat(...parts) {
|
|
40
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
41
|
+
let offset = 0;
|
|
42
|
+
for (const part of parts) {
|
|
43
|
+
out.set(part, offset);
|
|
44
|
+
offset += part.length;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function encodeSocialCommon(domain, fields) {
|
|
49
|
+
const encoder = new TextEncoder();
|
|
50
|
+
const network = encoder.encode(fields.network);
|
|
51
|
+
const kind = encoder.encode(fields.kind);
|
|
52
|
+
const value = encoder.encode(fields.value);
|
|
53
|
+
if (fields.address.length !== 32)
|
|
54
|
+
throw new Error("address must be 32 bytes");
|
|
55
|
+
if (fields.serverNonce.length !== 32)
|
|
56
|
+
throw new Error("server_nonce must be 32 bytes");
|
|
57
|
+
if (network.length > 0xff)
|
|
58
|
+
throw new Error("network too long");
|
|
59
|
+
if (kind.length > 0xff)
|
|
60
|
+
throw new Error("kind too long");
|
|
61
|
+
if (value.length > 0xffff)
|
|
62
|
+
throw new Error("value too long");
|
|
63
|
+
return [
|
|
64
|
+
domain,
|
|
65
|
+
fields.address,
|
|
66
|
+
new Uint8Array([network.length]),
|
|
67
|
+
network,
|
|
68
|
+
new Uint8Array([kind.length]),
|
|
69
|
+
kind,
|
|
70
|
+
u16be(value.length),
|
|
71
|
+
value,
|
|
72
|
+
u64be(fields.expiryUnixSecs),
|
|
73
|
+
fields.serverNonce,
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
export function encodeSocialIntentMessage(fields) {
|
|
77
|
+
if (fields.replacesNonce !== undefined && fields.replacesNonce.length !== 16) {
|
|
78
|
+
throw new Error("replaces_nonce must be 16 bytes");
|
|
79
|
+
}
|
|
80
|
+
return concat(...encodeSocialCommon(SOCIAL_INTENT_DOMAIN, fields), fields.replacesNonce === undefined
|
|
81
|
+
? new Uint8Array([0])
|
|
82
|
+
: concat(new Uint8Array([1]), fields.replacesNonce));
|
|
83
|
+
}
|
|
84
|
+
export function encodeSocialProofMessage(fields) {
|
|
85
|
+
if (fields.replacesNonce !== undefined && fields.replacesNonce.length !== 16) {
|
|
86
|
+
throw new Error("replaces_nonce must be 16 bytes");
|
|
87
|
+
}
|
|
88
|
+
return concat(...encodeSocialCommon(SOCIAL_PROOF_DOMAIN, fields), fields.replacesNonce === undefined
|
|
89
|
+
? new Uint8Array([0])
|
|
90
|
+
: concat(new Uint8Array([1]), fields.replacesNonce));
|
|
91
|
+
}
|
|
92
|
+
export function encodeSocialCommitMessage(fields) {
|
|
93
|
+
if (fields.proofNonce.length !== 16)
|
|
94
|
+
throw new Error("proof_nonce must be 16 bytes");
|
|
95
|
+
return concat(...encodeSocialCommon(SOCIAL_COMMIT_DOMAIN, fields), fields.proofNonce);
|
|
96
|
+
}
|
|
97
|
+
function canonicalOAuth(provider, raw) {
|
|
98
|
+
assertOAuthProvider(provider);
|
|
99
|
+
const value = provider === "github"
|
|
100
|
+
? raw
|
|
101
|
+
.trim()
|
|
102
|
+
.replace(/^https?:\/\/github\.com\//i, "")
|
|
103
|
+
.replace(/\/.*$/, "")
|
|
104
|
+
.toLowerCase()
|
|
105
|
+
: raw
|
|
106
|
+
.trim()
|
|
107
|
+
.replace(/^https?:\/\/orcid\.org\//i, "")
|
|
108
|
+
.replace(/x$/, "X");
|
|
109
|
+
if (!isCanonicalClaimValue(provider, value)) {
|
|
110
|
+
throw new InvalidProofInputError(provider, raw);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function canonicalWebsite(raw) {
|
|
115
|
+
let value = raw.trim().toLowerCase();
|
|
116
|
+
try {
|
|
117
|
+
if (value.includes("://"))
|
|
118
|
+
value = new URL(value).hostname;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// The canonical claim codec below remains the authority.
|
|
122
|
+
}
|
|
123
|
+
value = value.replace(/\/.*$/, "").replace(/\.$/, "");
|
|
124
|
+
if (!isCanonicalClaimValue("website", value)) {
|
|
125
|
+
throw new InvalidProofInputError("website", raw);
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
function canonicalX(raw) {
|
|
130
|
+
const value = raw
|
|
131
|
+
.trim()
|
|
132
|
+
.replace(/^https?:\/\/(?:www\.)?(?:x|twitter)\.com\//i, "")
|
|
133
|
+
.replace(/^@/, "")
|
|
134
|
+
.replace(/[/?#].*$/, "")
|
|
135
|
+
.toLowerCase();
|
|
136
|
+
if (!isCanonicalClaimValue("x", value)) {
|
|
137
|
+
throw new InvalidProofInputError("x", raw);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
function cancelled(signal) {
|
|
142
|
+
if (signal?.aborted)
|
|
143
|
+
throw new ProofWaitCancelledError();
|
|
144
|
+
}
|
|
145
|
+
function validateWaitOptions(options) {
|
|
146
|
+
if (!Number.isFinite(options.timeoutMs) ||
|
|
147
|
+
options.timeoutMs <= 0 ||
|
|
148
|
+
options.timeoutMs > MAX_TIMER_MS) {
|
|
149
|
+
throw new RangeError(`proof timeoutMs must be between 1 and ${MAX_TIMER_MS}`);
|
|
150
|
+
}
|
|
151
|
+
if (!Number.isFinite(options.intervalMs) ||
|
|
152
|
+
options.intervalMs < 0 ||
|
|
153
|
+
options.intervalMs > MAX_TIMER_MS) {
|
|
154
|
+
throw new RangeError(`proof intervalMs must be between 0 and ${MAX_TIMER_MS}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function snapshotWaitOptions(options) {
|
|
158
|
+
const timeoutMs = options.timeoutMs;
|
|
159
|
+
const intervalMs = options.intervalMs;
|
|
160
|
+
const signal = options.signal;
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
timeoutMs,
|
|
163
|
+
intervalMs,
|
|
164
|
+
...(signal === undefined ? {} : { signal }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
async function boundedWait(work, options, started, timeoutError) {
|
|
168
|
+
cancelled(options.signal);
|
|
169
|
+
const remaining = options.timeoutMs - (Date.now() - started);
|
|
170
|
+
if (remaining <= 0)
|
|
171
|
+
throw timeoutError();
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
let timedOut = false;
|
|
174
|
+
let externallyCancelled = false;
|
|
175
|
+
const onExternalAbort = () => {
|
|
176
|
+
externallyCancelled = true;
|
|
177
|
+
controller.abort();
|
|
178
|
+
};
|
|
179
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
180
|
+
controller.signal.addEventListener("abort", () => reject(timedOut ? timeoutError() : new ProofWaitCancelledError()), { once: true });
|
|
181
|
+
});
|
|
182
|
+
options.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
183
|
+
if (options.signal?.aborted)
|
|
184
|
+
onExternalAbort();
|
|
185
|
+
const timer = setTimeout(() => {
|
|
186
|
+
timedOut = true;
|
|
187
|
+
controller.abort();
|
|
188
|
+
}, remaining);
|
|
189
|
+
try {
|
|
190
|
+
return await Promise.race([work(controller.signal), aborted]);
|
|
191
|
+
}
|
|
192
|
+
catch (cause) {
|
|
193
|
+
if (timedOut)
|
|
194
|
+
throw timeoutError();
|
|
195
|
+
if (externallyCancelled || options.signal?.aborted) {
|
|
196
|
+
throw new ProofWaitCancelledError();
|
|
197
|
+
}
|
|
198
|
+
throw cause;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
options.signal?.removeEventListener("abort", onExternalAbort);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function delay(ms, signal) {
|
|
206
|
+
cancelled(signal);
|
|
207
|
+
if (ms <= 0)
|
|
208
|
+
return;
|
|
209
|
+
await new Promise((resolve, reject) => {
|
|
210
|
+
let settled = false;
|
|
211
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
212
|
+
const onAbort = () => {
|
|
213
|
+
if (settled)
|
|
214
|
+
return;
|
|
215
|
+
settled = true;
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
cleanup();
|
|
218
|
+
reject(new ProofWaitCancelledError());
|
|
219
|
+
};
|
|
220
|
+
const timer = setTimeout(() => {
|
|
221
|
+
if (settled)
|
|
222
|
+
return;
|
|
223
|
+
settled = true;
|
|
224
|
+
cleanup();
|
|
225
|
+
resolve();
|
|
226
|
+
}, ms);
|
|
227
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
228
|
+
if (signal?.aborted)
|
|
229
|
+
onAbort();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function exactAvailable(body) {
|
|
233
|
+
return (typeof body === "object" &&
|
|
234
|
+
body !== null &&
|
|
235
|
+
!Array.isArray(body) &&
|
|
236
|
+
Object.keys(body).length === 1 &&
|
|
237
|
+
Object.prototype.hasOwnProperty.call(body, "available") &&
|
|
238
|
+
body.available === true);
|
|
239
|
+
}
|
|
240
|
+
async function readJson(response) {
|
|
241
|
+
return response.json().catch(() => null);
|
|
242
|
+
}
|
|
243
|
+
async function challengeFor(config, signer, fetchImpl, signal) {
|
|
244
|
+
cancelled(signal);
|
|
245
|
+
let response;
|
|
246
|
+
try {
|
|
247
|
+
response = await fetchImpl(`${config.idOrigin}/api/profile/challenge`, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "content-type": "application/json" },
|
|
250
|
+
body: JSON.stringify({ address: signer.signerHex, network: config.networkId }),
|
|
251
|
+
signal,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (cause) {
|
|
255
|
+
cancelled(signal);
|
|
256
|
+
throw new ProofProtocolError("could not obtain a proof challenge", { cause });
|
|
257
|
+
}
|
|
258
|
+
const body = (await readJson(response));
|
|
259
|
+
if (response.status !== 200 ||
|
|
260
|
+
body === null ||
|
|
261
|
+
!Number.isSafeInteger(body.expiry) ||
|
|
262
|
+
body.expiry < 0 ||
|
|
263
|
+
typeof body.server_nonce !== "string" ||
|
|
264
|
+
!/^[0-9a-f]{64}$/.test(body.server_nonce)) {
|
|
265
|
+
throw new ProofProtocolError("the proof challenge response was unusable");
|
|
266
|
+
}
|
|
267
|
+
return { expiry: body.expiry, serverNonce: body.server_nonce };
|
|
268
|
+
}
|
|
269
|
+
async function signLocallyVerified(signer, message) {
|
|
270
|
+
let signature;
|
|
271
|
+
try {
|
|
272
|
+
signature = await signer.sign(new Uint8Array(message));
|
|
273
|
+
}
|
|
274
|
+
catch (cause) {
|
|
275
|
+
throw new ProofProtocolError("the proof message was not signed", { cause });
|
|
276
|
+
}
|
|
277
|
+
if (!verifyStrict(message, signature, signer.publicKey)) {
|
|
278
|
+
throw new LocalVerificationError();
|
|
279
|
+
}
|
|
280
|
+
return signature;
|
|
281
|
+
}
|
|
282
|
+
class XSession {
|
|
283
|
+
handle;
|
|
284
|
+
proofUrl;
|
|
285
|
+
nonce;
|
|
286
|
+
expiresAt;
|
|
287
|
+
status = "issued";
|
|
288
|
+
#config;
|
|
289
|
+
#signer;
|
|
290
|
+
#claims;
|
|
291
|
+
#fetch;
|
|
292
|
+
#protocol;
|
|
293
|
+
#verification;
|
|
294
|
+
#settlement;
|
|
295
|
+
constructor(dependencies, handle, proofUrl, nonce, expiresAt, protocol) {
|
|
296
|
+
this.handle = handle;
|
|
297
|
+
this.proofUrl = proofUrl;
|
|
298
|
+
this.nonce = nonce;
|
|
299
|
+
this.expiresAt = expiresAt;
|
|
300
|
+
this.#config = dependencies.config;
|
|
301
|
+
this.#signer = dependencies.signer;
|
|
302
|
+
this.#claims = dependencies.claims;
|
|
303
|
+
this.#fetch = dependencies.fetchImpl;
|
|
304
|
+
this.#protocol = protocol;
|
|
305
|
+
Object.freeze(this);
|
|
306
|
+
}
|
|
307
|
+
getProofPost() {
|
|
308
|
+
const text = `${X_POST_LEAD_IN}\n\n${this.proofUrl}`;
|
|
309
|
+
return {
|
|
310
|
+
text,
|
|
311
|
+
composerUrl: `https://x.com/intent/post?${new URLSearchParams({ text }).toString()}`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
#recordVerification(result) {
|
|
315
|
+
if (!this.#verification?.verified ||
|
|
316
|
+
this.#verification.settlement !== "applied") {
|
|
317
|
+
this.#verification = result;
|
|
318
|
+
}
|
|
319
|
+
return { ...result };
|
|
320
|
+
}
|
|
321
|
+
async verifyPost(postUrl) {
|
|
322
|
+
let response;
|
|
323
|
+
try {
|
|
324
|
+
response = await this.#fetch(`${this.#config.idOrigin}/api/social/x/verify`, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { "content-type": "application/json" },
|
|
327
|
+
body: JSON.stringify({ proof_nonce: this.nonce, post_url: postUrl }),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return this.#recordVerification({
|
|
332
|
+
verified: false,
|
|
333
|
+
status: "network_error",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (response.status === 404) {
|
|
337
|
+
return this.#recordVerification({ verified: false, status: "unknown" });
|
|
338
|
+
}
|
|
339
|
+
if (response.status === 410) {
|
|
340
|
+
return this.#recordVerification({ verified: false, status: "gone" });
|
|
341
|
+
}
|
|
342
|
+
if (response.status === 400) {
|
|
343
|
+
return this.#recordVerification({ verified: false, status: "invalid" });
|
|
344
|
+
}
|
|
345
|
+
const body = (await readJson(response));
|
|
346
|
+
if (response.status === 503) {
|
|
347
|
+
const header = Number(response.headers.get("retry-after"));
|
|
348
|
+
const retryAfterSecs = Number.isFinite(header) && header > 0
|
|
349
|
+
? header
|
|
350
|
+
: typeof body?.retry_after === "number"
|
|
351
|
+
? body.retry_after
|
|
352
|
+
: undefined;
|
|
353
|
+
return this.#recordVerification(retryAfterSecs === undefined
|
|
354
|
+
? { verified: false, status: "retry" }
|
|
355
|
+
: { verified: false, status: "retry", retryAfterSecs });
|
|
356
|
+
}
|
|
357
|
+
if (response.status === 422) {
|
|
358
|
+
return this.#recordVerification(body?.verified === false && typeof body.reason === "string"
|
|
359
|
+
? { verified: false, status: "failed", reason: body.reason }
|
|
360
|
+
: { verified: false, status: "error" });
|
|
361
|
+
}
|
|
362
|
+
if (response.status !== 200 || body?.verified !== true) {
|
|
363
|
+
return this.#recordVerification({ verified: false, status: "error" });
|
|
364
|
+
}
|
|
365
|
+
const settlement = this.#protocol === INTENT_PROTOCOL &&
|
|
366
|
+
(body.settlement === "committed" || body.settlement === "applied")
|
|
367
|
+
? body.settlement
|
|
368
|
+
: undefined;
|
|
369
|
+
return this.#recordVerification(settlement === undefined
|
|
370
|
+
? { verified: true }
|
|
371
|
+
: { verified: true, settlement });
|
|
372
|
+
}
|
|
373
|
+
async settle() {
|
|
374
|
+
if (this.#verification?.verified && this.#verification.settlement === "applied") {
|
|
375
|
+
return { status: "already-settled" };
|
|
376
|
+
}
|
|
377
|
+
if (this.#settlement)
|
|
378
|
+
return this.#settlement;
|
|
379
|
+
if (!this.#verification?.verified) {
|
|
380
|
+
throw new XSettlementNotAuthorizedError("the proof post has not been verified");
|
|
381
|
+
}
|
|
382
|
+
if (this.#verification.settlement === undefined &&
|
|
383
|
+
this.#protocol === INTENT_PROTOCOL) {
|
|
384
|
+
throw new XSettlementNotAuthorizedError("the verification omitted the committed/applied tag; only the legacy protocol may use /commit");
|
|
385
|
+
}
|
|
386
|
+
const settlement = this.#settleOnce();
|
|
387
|
+
this.#settlement = settlement;
|
|
388
|
+
try {
|
|
389
|
+
return await settlement;
|
|
390
|
+
}
|
|
391
|
+
catch (cause) {
|
|
392
|
+
if (provesNoPaidAttempt(cause) && this.#settlement === settlement) {
|
|
393
|
+
this.#settlement = undefined;
|
|
394
|
+
}
|
|
395
|
+
throw cause;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async #settleOnce() {
|
|
399
|
+
if (this.#verification?.verified && this.#verification.settlement === "applied") {
|
|
400
|
+
return { status: "already-settled" };
|
|
401
|
+
}
|
|
402
|
+
if (this.#verification?.verified && this.#verification.settlement === "committed") {
|
|
403
|
+
return this.#claims.settleProvenProperty("x", this.handle);
|
|
404
|
+
}
|
|
405
|
+
await this.#commitLegacy();
|
|
406
|
+
return this.#claims.settleProvenProperty("x", this.handle);
|
|
407
|
+
}
|
|
408
|
+
async #commitLegacy() {
|
|
409
|
+
const challenge = await challengeFor(this.#config, this.#signer, this.#fetch);
|
|
410
|
+
if (!/^[0-9a-f]{32}$/.test(this.nonce)) {
|
|
411
|
+
throw new XSettlementNotAuthorizedError("the proof nonce cannot be committed");
|
|
412
|
+
}
|
|
413
|
+
const message = encodeSocialCommitMessage({
|
|
414
|
+
address: this.#signer.publicKey,
|
|
415
|
+
network: this.#config.networkId,
|
|
416
|
+
kind: "x",
|
|
417
|
+
value: this.handle,
|
|
418
|
+
expiryUnixSecs: BigInt(challenge.expiry),
|
|
419
|
+
serverNonce: hexToBytes(challenge.serverNonce),
|
|
420
|
+
proofNonce: hexToBytes(this.nonce),
|
|
421
|
+
});
|
|
422
|
+
const signature = await signLocallyVerified(this.#signer, message);
|
|
423
|
+
const body = JSON.stringify({
|
|
424
|
+
address: this.#signer.signerHex,
|
|
425
|
+
network: this.#config.networkId,
|
|
426
|
+
value: this.handle,
|
|
427
|
+
expiry_unix_secs: challenge.expiry,
|
|
428
|
+
server_nonce: challenge.serverNonce,
|
|
429
|
+
signature,
|
|
430
|
+
proof_nonce: this.nonce,
|
|
431
|
+
});
|
|
432
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
433
|
+
let response;
|
|
434
|
+
try {
|
|
435
|
+
response = await this.#fetch(`${this.#config.idOrigin}/api/social/x/commit`, {
|
|
436
|
+
method: "POST",
|
|
437
|
+
headers: { "content-type": "application/json" },
|
|
438
|
+
body,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
catch (cause) {
|
|
442
|
+
throw new XSettlementNotAuthorizedError(`the legacy /commit result is unknown: ${cause instanceof Error ? cause.message : "network error"}`);
|
|
443
|
+
}
|
|
444
|
+
const answer = await readJson(response);
|
|
445
|
+
if (response.status === 200 &&
|
|
446
|
+
typeof answer === "object" &&
|
|
447
|
+
answer !== null &&
|
|
448
|
+
answer.committed === true &&
|
|
449
|
+
Object.keys(answer).length === 1) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (response.status !== 429 || attempt === 3) {
|
|
453
|
+
throw new XSettlementNotAuthorizedError(`legacy /commit did not confirm commitment (status ${response.status})`);
|
|
454
|
+
}
|
|
455
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
456
|
+
const jsonRetry = typeof answer === "object" &&
|
|
457
|
+
answer !== null &&
|
|
458
|
+
typeof answer.retry_after === "number"
|
|
459
|
+
? answer.retry_after
|
|
460
|
+
: undefined;
|
|
461
|
+
const seconds = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : jsonRetry;
|
|
462
|
+
const waitMs = seconds === undefined ? 1_000 : seconds * 1_000;
|
|
463
|
+
if (!Number.isFinite(waitMs) || waitMs > 15_000) {
|
|
464
|
+
throw new XSettlementNotAuthorizedError("legacy /commit remained indeterminate after rate limiting");
|
|
465
|
+
}
|
|
466
|
+
await delay(waitMs);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
export class ProofService {
|
|
471
|
+
#config;
|
|
472
|
+
#signer;
|
|
473
|
+
#reads;
|
|
474
|
+
#claims;
|
|
475
|
+
#fetch;
|
|
476
|
+
#oauthSettlements = new Map();
|
|
477
|
+
constructor(options) {
|
|
478
|
+
this.#config = options.config;
|
|
479
|
+
this.#signer = options.signer;
|
|
480
|
+
this.#reads = options.reads;
|
|
481
|
+
this.#claims = options.claims;
|
|
482
|
+
this.#fetch = options.fetchImpl;
|
|
483
|
+
}
|
|
484
|
+
oauthClaimInstructions(provider) {
|
|
485
|
+
assertOAuthProvider(provider);
|
|
486
|
+
return {
|
|
487
|
+
url: `${this.#config.idOrigin}/claim/${provider}`,
|
|
488
|
+
instructions: `Open this Fast ID claim page in the browser that will complete ${provider} authentication. ` +
|
|
489
|
+
`Connect the same wallet identity as the SDK signer on ${this.#config.networkId}. ` +
|
|
490
|
+
"The SDK does not receive the OAuth callback, code, token, or browser cookie; supply the known canonical identity when polling.",
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
async waitForOAuthProof(provider, rawValue, options) {
|
|
494
|
+
const value = canonicalOAuth(provider, rawValue);
|
|
495
|
+
const waitOptions = snapshotWaitOptions(options);
|
|
496
|
+
validateWaitOptions(waitOptions);
|
|
497
|
+
const started = Date.now();
|
|
498
|
+
for (;;) {
|
|
499
|
+
cancelled(waitOptions.signal);
|
|
500
|
+
if (Date.now() - started >= waitOptions.timeoutMs) {
|
|
501
|
+
throw new OAuthProofTimeoutError(provider, value);
|
|
502
|
+
}
|
|
503
|
+
if (await boundedWait((signal) => this.#oauthProofAvailable(provider, value, signal), waitOptions, started, () => new OAuthProofTimeoutError(provider, value))) {
|
|
504
|
+
return {
|
|
505
|
+
available: true,
|
|
506
|
+
provider,
|
|
507
|
+
value,
|
|
508
|
+
addressHex: this.#signer.signerHex,
|
|
509
|
+
network: this.#config.networkId,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const remaining = waitOptions.timeoutMs - (Date.now() - started);
|
|
513
|
+
await delay(Math.min(Math.max(waitOptions.intervalMs, 0), Math.max(remaining, 0)), waitOptions.signal);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
async settleOAuthClaim(provider, rawValue) {
|
|
517
|
+
const value = canonicalOAuth(provider, rawValue);
|
|
518
|
+
const key = `${provider}:${value}`;
|
|
519
|
+
const existing = this.#oauthSettlements.get(key);
|
|
520
|
+
if (existing)
|
|
521
|
+
return existing;
|
|
522
|
+
const settlement = this.#settleOAuthOnce(provider, value);
|
|
523
|
+
this.#oauthSettlements.set(key, settlement);
|
|
524
|
+
try {
|
|
525
|
+
return await settlement;
|
|
526
|
+
}
|
|
527
|
+
catch (cause) {
|
|
528
|
+
if (provesNoPaidAttempt(cause) &&
|
|
529
|
+
this.#oauthSettlements.get(key) === settlement) {
|
|
530
|
+
this.#oauthSettlements.delete(key);
|
|
531
|
+
}
|
|
532
|
+
throw cause;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
forgetOAuthSettlement(provider, rawValue) {
|
|
536
|
+
const value = canonicalOAuth(provider, rawValue);
|
|
537
|
+
this.#oauthSettlements.delete(`${provider}:${value}`);
|
|
538
|
+
}
|
|
539
|
+
async #settleOAuthOnce(provider, value) {
|
|
540
|
+
let available = false;
|
|
541
|
+
try {
|
|
542
|
+
available = await this.#oauthProofAvailable(provider, value);
|
|
543
|
+
}
|
|
544
|
+
catch (cause) {
|
|
545
|
+
throw new OAuthProofUnavailableError(provider, value, { cause });
|
|
546
|
+
}
|
|
547
|
+
if (!available)
|
|
548
|
+
throw new OAuthProofUnavailableError(provider, value);
|
|
549
|
+
return this.#claims.settleProvenProperty(provider, value);
|
|
550
|
+
}
|
|
551
|
+
claimWebsite(rawHost) {
|
|
552
|
+
const host = canonicalWebsite(rawHost);
|
|
553
|
+
let settlement;
|
|
554
|
+
let verified;
|
|
555
|
+
return {
|
|
556
|
+
host,
|
|
557
|
+
proofUrl: `https://${host}/.well-known/fastid`,
|
|
558
|
+
proofText: `${this.#signer.address}\n`,
|
|
559
|
+
instructions: `Publish a plain-text file at https://${host}/.well-known/fastid containing ` +
|
|
560
|
+
`${this.#signer.address} on one line, and keep it published to remain verified.`,
|
|
561
|
+
verifyAndSettle: (options = { timeoutMs: 60_000, intervalMs: 1_000 }) => {
|
|
562
|
+
if (verified)
|
|
563
|
+
return Promise.resolve(verified);
|
|
564
|
+
let waitOptions;
|
|
565
|
+
try {
|
|
566
|
+
waitOptions = snapshotWaitOptions(options);
|
|
567
|
+
}
|
|
568
|
+
catch (cause) {
|
|
569
|
+
return Promise.reject(cause);
|
|
570
|
+
}
|
|
571
|
+
if (!settlement) {
|
|
572
|
+
try {
|
|
573
|
+
validateWaitOptions(waitOptions);
|
|
574
|
+
cancelled(waitOptions.signal);
|
|
575
|
+
}
|
|
576
|
+
catch (cause) {
|
|
577
|
+
return Promise.reject(cause);
|
|
578
|
+
}
|
|
579
|
+
const attempt = this.#settleWebsite(host);
|
|
580
|
+
settlement = attempt;
|
|
581
|
+
void attempt.catch((cause) => {
|
|
582
|
+
if (provesNoPaidAttempt(cause) && settlement === attempt) {
|
|
583
|
+
settlement = undefined;
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return this.#waitForWebsite(host, waitOptions, settlement).then((result) => {
|
|
588
|
+
verified = result;
|
|
589
|
+
return result;
|
|
590
|
+
});
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
async startXClaim(rawHandle) {
|
|
595
|
+
const handle = canonicalX(rawHandle);
|
|
596
|
+
let capability;
|
|
597
|
+
try {
|
|
598
|
+
const response = await this.#fetch(`${this.#config.idOrigin}/api/social/kinds`, {
|
|
599
|
+
method: "GET",
|
|
600
|
+
headers: { accept: "application/json" },
|
|
601
|
+
cache: "no-store",
|
|
602
|
+
});
|
|
603
|
+
capability = response.status === 200 ? await readJson(response) : null;
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
capability = null;
|
|
607
|
+
}
|
|
608
|
+
const kinds = typeof capability === "object" && capability !== null
|
|
609
|
+
? capability.kinds
|
|
610
|
+
: undefined;
|
|
611
|
+
const intentKinds = typeof capability === "object" && capability !== null
|
|
612
|
+
? capability.intent_kinds
|
|
613
|
+
: undefined;
|
|
614
|
+
if (!Array.isArray(kinds) ||
|
|
615
|
+
!kinds.every((kind) => typeof kind === "string") ||
|
|
616
|
+
!kinds.includes("x")) {
|
|
617
|
+
throw new ProofProtocolError("X claims are not available on this network");
|
|
618
|
+
}
|
|
619
|
+
const intentEnabled = Array.isArray(intentKinds) &&
|
|
620
|
+
intentKinds.every((kind) => typeof kind === "string") &&
|
|
621
|
+
intentKinds.includes("x");
|
|
622
|
+
const challenge = await challengeFor(this.#config, this.#signer, this.#fetch);
|
|
623
|
+
const fields = {
|
|
624
|
+
address: this.#signer.publicKey,
|
|
625
|
+
network: this.#config.networkId,
|
|
626
|
+
kind: "x",
|
|
627
|
+
value: handle,
|
|
628
|
+
expiryUnixSecs: BigInt(challenge.expiry),
|
|
629
|
+
serverNonce: hexToBytes(challenge.serverNonce),
|
|
630
|
+
};
|
|
631
|
+
const message = intentEnabled
|
|
632
|
+
? encodeSocialIntentMessage(fields)
|
|
633
|
+
: encodeSocialProofMessage(fields);
|
|
634
|
+
const signature = await signLocallyVerified(this.#signer, message);
|
|
635
|
+
const route = intentEnabled ? "intent" : "proof";
|
|
636
|
+
let response;
|
|
637
|
+
try {
|
|
638
|
+
response = await this.#fetch(`${this.#config.idOrigin}/api/social/x/${route}`, {
|
|
639
|
+
method: "POST",
|
|
640
|
+
headers: { "content-type": "application/json" },
|
|
641
|
+
body: JSON.stringify({
|
|
642
|
+
address: this.#signer.signerHex,
|
|
643
|
+
network: this.#config.networkId,
|
|
644
|
+
value: handle,
|
|
645
|
+
expiry_unix_secs: challenge.expiry,
|
|
646
|
+
server_nonce: challenge.serverNonce,
|
|
647
|
+
signature,
|
|
648
|
+
}),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
catch (cause) {
|
|
652
|
+
throw new ProofProtocolError(`X ${route} submission failed`, { cause });
|
|
653
|
+
}
|
|
654
|
+
const answer = (await readJson(response));
|
|
655
|
+
if (response.status !== 200 || answer === null) {
|
|
656
|
+
throw new ProofProtocolError(`X ${route} was refused with status ${response.status}`);
|
|
657
|
+
}
|
|
658
|
+
if (intentEnabled &&
|
|
659
|
+
answer.settlement === "applied" &&
|
|
660
|
+
Object.keys(answer).length === 1) {
|
|
661
|
+
return { status: "already-settled" };
|
|
662
|
+
}
|
|
663
|
+
if (typeof answer.proof_url !== "string" ||
|
|
664
|
+
typeof answer.nonce !== "string" ||
|
|
665
|
+
!/^[0-9a-f]{32}$/.test(answer.nonce) ||
|
|
666
|
+
typeof answer.expires_at !== "string") {
|
|
667
|
+
throw new ProofProtocolError(`X ${route} returned an unusable proof receipt`);
|
|
668
|
+
}
|
|
669
|
+
const protocol = intentEnabled && answer.protocol === INTENT_PROTOCOL ? INTENT_PROTOCOL : null;
|
|
670
|
+
return new XSession({
|
|
671
|
+
config: this.#config,
|
|
672
|
+
signer: this.#signer,
|
|
673
|
+
claims: this.#claims,
|
|
674
|
+
fetchImpl: this.#fetch,
|
|
675
|
+
}, handle, answer.proof_url, answer.nonce, answer.expires_at, protocol);
|
|
676
|
+
}
|
|
677
|
+
async #oauthProofAvailable(provider, value, signal) {
|
|
678
|
+
cancelled(signal);
|
|
679
|
+
const query = new URLSearchParams({
|
|
680
|
+
address: this.#signer.signerHex,
|
|
681
|
+
kind: provider,
|
|
682
|
+
value,
|
|
683
|
+
});
|
|
684
|
+
let response;
|
|
685
|
+
try {
|
|
686
|
+
response = await this.#fetch(`${this.#config.idOrigin}/api/oauth/proof-available?${query}`, { signal });
|
|
687
|
+
}
|
|
688
|
+
catch (cause) {
|
|
689
|
+
if (signal?.aborted)
|
|
690
|
+
throw cause;
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
const body = await readJson(response);
|
|
694
|
+
cancelled(signal);
|
|
695
|
+
return response.status === 200 && exactAvailable(body);
|
|
696
|
+
}
|
|
697
|
+
async #settleWebsite(host) {
|
|
698
|
+
try {
|
|
699
|
+
const settled = await this.#claims.settleProvenProperty("website", host);
|
|
700
|
+
return {
|
|
701
|
+
txIdHex: settled.txIdHex,
|
|
702
|
+
nonce: settled.nonce,
|
|
703
|
+
...(settled.outcome === "verified" ? { terminal: settled } : {}),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
catch (cause) {
|
|
707
|
+
if (!(cause instanceof RegistrationPendingError))
|
|
708
|
+
throw cause;
|
|
709
|
+
return {
|
|
710
|
+
txIdHex: cause.pending.txIdHex,
|
|
711
|
+
nonce: cause.pending.nonce,
|
|
712
|
+
pendingRegistration: cause,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async #waitForWebsite(host, options, settlement) {
|
|
717
|
+
const state = await settlement;
|
|
718
|
+
if (state.terminal)
|
|
719
|
+
return state.terminal;
|
|
720
|
+
validateWaitOptions(options);
|
|
721
|
+
const { txIdHex, nonce, pendingRegistration } = state;
|
|
722
|
+
const started = Date.now();
|
|
723
|
+
try {
|
|
724
|
+
for (;;) {
|
|
725
|
+
cancelled(options.signal);
|
|
726
|
+
try {
|
|
727
|
+
const links = await boundedWait((signal) => this.#reads.links(this.#signer.address, signal), options, started, () => pendingRegistration ??
|
|
728
|
+
new WebsiteVerificationPendingError(host, txIdHex, nonce));
|
|
729
|
+
if (links.address === this.#signer.address &&
|
|
730
|
+
links.network === this.#config.networkId &&
|
|
731
|
+
links.links.some((link) => link.kind === "website" &&
|
|
732
|
+
link.value === host &&
|
|
733
|
+
link.status === "verified" &&
|
|
734
|
+
link.claim_tx === txIdHex)) {
|
|
735
|
+
return {
|
|
736
|
+
txIdHex,
|
|
737
|
+
nonce,
|
|
738
|
+
registration: "registered",
|
|
739
|
+
outcome: "verified",
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
catch (cause) {
|
|
744
|
+
if (cause instanceof ProofWaitCancelledError ||
|
|
745
|
+
cause instanceof RegistrationPendingError ||
|
|
746
|
+
cause instanceof WebsiteVerificationPendingError) {
|
|
747
|
+
throw cause;
|
|
748
|
+
}
|
|
749
|
+
// An unavailable read is absence of evidence and never a verified result.
|
|
750
|
+
}
|
|
751
|
+
if (Date.now() - started >= options.timeoutMs) {
|
|
752
|
+
if (pendingRegistration)
|
|
753
|
+
throw pendingRegistration;
|
|
754
|
+
throw new WebsiteVerificationPendingError(host, txIdHex, nonce);
|
|
755
|
+
}
|
|
756
|
+
const remaining = options.timeoutMs - (Date.now() - started);
|
|
757
|
+
await delay(Math.min(Math.max(options.intervalMs, 0), Math.max(remaining, 0)), options.signal);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
catch (cause) {
|
|
761
|
+
if (cause instanceof ProofWaitCancelledError) {
|
|
762
|
+
if (pendingRegistration)
|
|
763
|
+
throw pendingRegistration;
|
|
764
|
+
throw new WebsiteVerificationPendingError(host, txIdHex, nonce);
|
|
765
|
+
}
|
|
766
|
+
throw cause;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
//# sourceMappingURL=proofs.js.map
|