~james/core

core/web/src/lib/api/signup.ts -rw-r--r-- 4.6 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import { isDid, type Did } from "@atcute/lexicons/syntax";
import { buildUrl, toResponseError } from "#lib/api/_request.ts";
import { nsidOf } from "#lib/api/syntax.ts";
import type { AuthorizationResponse } from "#lib/auth.svelte.ts";
import { sentence } from "#lib/format.ts";
import type { Fetch } from "#lib/fetch.ts";

const BEGIN_SIGNUP = nsidOf("org.tangled.temp.account.beginSignup");
const RESEND_SIGNUP = nsidOf("org.tangled.temp.account.resendSignup");
const GET_SIGNUP = nsidOf("org.tangled.temp.account.getSignup");
const COMPLETE_SIGNUP = nsidOf("org.tangled.temp.account.completeSignup");

const SERVER_FAILURE = "Unable to reach Tangled. Try again in a moment.";

export class SignupError extends Error {
	constructor(
		message: string,
		readonly code?: string
	) {
		super(message);
	}
}

const signupError = async (response: Response, fallback: string): Promise<SignupError> => {
	if (response.status >= 500) return new SignupError(SERVER_FAILURE);
	const err = await toResponseError(response);
	// "XRPCError" is toResponseError's sentinel for non-JSON bodies, not a service code
	const code = err.error && err.error !== "XRPCError" ? err.error : undefined;
	if (err.description) return new SignupError(sentence(err.description), code);
	return new SignupError(code ?? fallback, code);
};

// open endpoints: no service auth, the turnstile challenge guards beginSignup
export const beginSignup = async (
	serviceUrl: string,
	email: string,
	turnstileToken: string,
	fetchFn: Fetch = fetch
): Promise<void> => {
	const response = await fetchFn(buildUrl(serviceUrl, BEGIN_SIGNUP), {
		method: "POST",
		headers: { "content-type": "application/json", accept: "application/json" },
		body: JSON.stringify({ email, turnstileToken })
	});
	if (!response.ok) {
		throw await signupError(
			response,
			`Unable to send the link (HTTP ${response.status}). Try again in a moment.`
		);
	}
};

export const resendSignup = async (
	serviceUrl: string,
	email: string,
	fetchFn: Fetch = fetch
): Promise<void> => {
	const response = await fetchFn(buildUrl(serviceUrl, RESEND_SIGNUP), {
		method: "POST",
		headers: { "content-type": "application/json", accept: "application/json" },
		body: JSON.stringify({ email })
	});
	if (!response.ok) {
		throw await signupError(
			response,
			`Unable to resend the email (HTTP ${response.status}). Try again in a moment.`
		);
	}
};

export const getSignup = async (
	serviceUrl: string,
	token: string,
	fetchFn: Fetch = fetch
): Promise<{ email: string; pds?: string }> => {
	const response = await fetchFn(buildUrl(serviceUrl, GET_SIGNUP, { token }), {
		headers: { accept: "application/json" }
	});
	if (!response.ok) {
		throw await signupError(response, `Unable to check the link (HTTP ${response.status}).`);
	}
	const body = (await response.json().catch(() => null)) as {
		email?: unknown;
		pds?: unknown;
	} | null;
	if (typeof body?.email !== "string") {
		throw new SignupError("Unable to check the link. Try again in a moment.");
	}
	// a deliberi without the field leaves the page to sign in through the pds
	return typeof body.pds === "string"
		? { email: body.email, pds: body.pds }
		: { email: body.email };
};

export interface CompleteSignupInput {
	token: string;
	username: string;
	password: string;
	requestUri?: string;
}

export interface CompletedSignup {
	did: Did;
	handle: string;
	authorization?: AuthorizationResponse;
}

const isAuthorization = (value: unknown): value is AuthorizationResponse =>
	!!value &&
	typeof value === "object" &&
	["code", "state", "iss"].every(
		(key) => typeof (value as Record<string, unknown>)[key] === "string"
	);

export const completeSignup = async (
	serviceUrl: string,
	input: CompleteSignupInput,
	fetchFn: Fetch = fetch
): Promise<CompletedSignup> => {
	const response = await fetchFn(buildUrl(serviceUrl, COMPLETE_SIGNUP), {
		method: "POST",
		headers: { "content-type": "application/json", accept: "application/json" },
		body: JSON.stringify(input)
	});
	if (!response.ok) {
		throw await signupError(
			response,
			`Unable to create the account (HTTP ${response.status}). Try again in a moment.`
		);
	}
	// a successful signup must return both fields; anything else is a broken
	// response, and the pages must not render "Account created" for it
	const body = (await response.json().catch(() => null)) as {
		did?: unknown;
		handle?: unknown;
		authorization?: unknown;
	} | null;
	if (!isDid(body?.did) || typeof body.handle !== "string") {
		throw new Error("Unable to create the account. Try again in a moment.");
	}
	const { did, handle, authorization } = body;
	return isAuthorization(authorization) ? { did, handle, authorization } : { did, handle };
};